The RecordParser Trait¶
The RecordParser trait turns a byte chunk into field/value events fed to a
ColumnarSink. This is where format-specific parsing lives.
See Architecture for how the engine calls the parser and how the sink accumulates values.
Trait definition¶
pub trait RecordParser: Send + Sync {
/// Validate that the whole byte slice is well-formed.
fn validate(&self, bytes: &[u8]) -> Result<()>;
/// Parse one chunk and feed all row events into `sink`.
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()>;
/// Parse one chunk with monomorphized sink (inlinable, devirtualized).
/// Default delegates to parse_chunk.
fn parse_chunk_generic<S: ColumnarSink>(&self, bytes: &[u8], sink: &mut S) -> Result<()>
where Self: Sized;
}
validate¶
Called once per chunk before parsing. Use it for upfront checks like UTF-8 validation. This is cheap (SIMD-accelerated) and catches malformed input early.
fn validate(&self, bytes: &[u8]) -> Result<()> {
simdutf8::basic::from_utf8(bytes).map_err(rypipe_core::Error::Utf8)?;
Ok(())
}
parse_chunk¶
The main parsing loop. For each record in the chunk:
- Call
sink.begin_row()to start a new row - For each field, call
sink.put_field(name, value)or faster alternatives - Call
sink.end_row()to finalize the row
The engine calls this once per chunk. Your parser sees a contiguous byte range
that starts and ends at record boundaries (guaranteed by the Splitter).
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
let text = std::str::from_utf8(bytes)
.map_err(|e| rypipe_core::Error::Plan(e.to_string()))?;
for line in text.lines() {
if line.is_empty() { continue; }
sink.begin_row();
for (col, value) in self.header.iter().zip(line.split(',')) {
sink.put_field(col, Value::Str(Cow::Borrowed(value)));
}
sink.end_row();
}
Ok(())
}
parse_chunk_generic¶
The generic version allows the compiler to devirtualize sink calls (no vtable
dispatch). When the engine knows the concrete sink type (e.g., TableBuilder),
it calls parse_chunk_generic instead of parse_chunk.
fn parse_chunk_generic<S: ColumnarSink>(&self, bytes: &[u8], sink: &mut S) -> Result<()> {
// Same body as parse_chunk, but sink calls are devirtualized.
self.parse_chunk(bytes, sink as &mut dyn ColumnarSink)
}
Override this method for a measurable speedup on hot paths. The compiler can
then inline sink.begin_row(), sink.put_field(), and sink.end_row() into
the parsing loop, eliminating vtable dispatch overhead.
Performance tips¶
1. Use Cow::Borrowed for non-entity text¶
Borrow from the input buffer when possible:
The buffered filter path may hold values past the end of your parse function,
so a borrow of a temporary would dangle. Cow::Borrowed is safe because it
borrows from the chunk's byte slice, which lives long enough.
2. Check wants() before expensive extraction¶
Skip dropped fields entirely:
When wants() returns false, the engine will drop the column. Checking
before extraction saves the cost of parsing, decoding, and allocating.
3. Use resolve + put_field_resolved for expensive extraction¶
When extraction is costly (entity unescaping, base64 decode, date parsing), resolve once and push with the resolved name to avoid a second hash probe:
if let Some(resolved) = sink.resolve(col) {
let decoded = expensive_decode(value);
sink.put_field_resolved(resolved, Value::Str(Cow::Owned(decoded)));
}
This pays the rename→drop hash lookup once instead of twice (once in
resolve_and_put's internal resolve, once in put_field's internal
ensure_column_idx).
4. Emit typed Value variants when possible¶
// Instead of:
sink.put_field("amount", Value::Str(Cow::Borrowed("123.45")));
// Do:
sink.put_field("amount", Value::Float64(123.45));
Typed variants skip the string-to-number conversion in the engine. The engine still stores the value correctly and exports it as the right Arrow type.
5. Do not call end_row() for partial trailing rows¶
If your parser reaches the end of the chunk mid-record, just return. The engine
discards partial trailing rows automatically during normalize().
6. Consider parse_chunk_generic for hot paths¶
If your parser is called millions of times (e.g., small files, streaming),
override parse_chunk_generic to get devirtualized sink calls:
fn parse_chunk_generic<S: ColumnarSink>(&self, bytes: &[u8], sink: &mut S) -> Result<()> {
let text = std::str::from_utf8(bytes)
.map_err(|e| rypipe_core::Error::Plan(e.to_string()))?;
for line in text.lines() {
if line.is_empty() { continue; }
sink.begin_row();
for (col, value) in self.header.iter().zip(line.split(',')) {
if sink.wants(col) {
sink.put_field(col, Value::Str(Cow::Borrowed(value)));
}
}
sink.end_row();
}
Ok(())
}
The parsing lifecycle¶
validate(bytes) ← called once per chunk
│
▼
parse_chunk(bytes, sink) ← called once per chunk
│
├─ sink.begin_row() ← clear per-row state
├─ sink.put_field("a", val) ← push field (engine resolves + stores)
├─ sink.put_field("b", val) ← push field
├─ sink.end_row() ← null-fill missing, evaluate filter
├─ sink.begin_row() ← next row
├─ ...
└─ sink.end_row() ← last row
│
▼
sink.finish() ← called once after all chunks
│
▼
Arrow RecordBatch ← zero-copy export
Error handling¶
Return Err from parse_chunk to abort parsing. The engine will propagate
the error to the caller. Common error types:
rypipe_core::Error::Utf8: invalid UTF-8 in inputrypipe_core::Error::Plan: invalid plan or configurationrypipe_core::Error::Io: I/O error
Do not panic in parse_chunk. Panics are caught by catch_unwind in the
parallel executor, but they abort the entire parse.