Adapter design¶
A high-performance adapter does as little work as possible per record. This page covers the Splitter and RecordParser design patterns that keep rypipe fast.
Splitter design¶
The splitter finds safe chunk boundaries for parallel parsing.
pub trait Splitter: Send + Sync {
fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize>;
fn estimate_bytes_per_row(&self, sample: &[u8]) -> usize;
fn skip_regions(&self) -> Option<&dyn SkipRegionFinder> { None }
fn find_split_points(&self, bytes: &[u8], max_chunks: usize) -> Vec<usize>;
}
find_split_points has a default implementation that uses next_record_start to find split points.
Rules:
- The first point must be
0; the last must bebytes.len(). - Adjacent equal points produce empty ranges that the engine ignores.
- Each chunk must start at a valid row boundary.
A good splitter is cheap. It scans for boundaries with byte searches rather than parsing the whole chunk. For line-oriented formats, memchr::memchr finds newlines. For XML, memchr::memmem finds row tags.
Complete Splitter example: newline-delimited log format¶
Here is a complete, annotated Splitter implementation for a newline-delimited format:
use rypipe_core::Splitter;
use rypipe_core::decoder::SkipRegionFinder;
/// Splitter for newline-delimited log files.
/// Each line is one record. Fields are separated by `=`.
struct LogSplitter;
impl Splitter for LogSplitter {
/// Find the next record boundary at or after `from`.
/// Returns the byte offset of the first byte of the next record,
/// or `None` if no more records exist.
fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize> {
memchr::memchr(b'\n', &bytes[from..])
.map(|rel| from + rel + 1) // position past the newline
}
/// Estimate average bytes per row from a 64 KB sample.
/// The bounded executor uses this to plan chunk sizes.
fn estimate_bytes_per_row(&self, sample: &[u8]) -> usize {
let n = sample.iter().filter(|&&b| b == b'\n').count().max(1);
(sample.len() / n).max(1)
}
// skip_regions() and find_split_points() use defaults.
// The default find_split_points handles nominal offsets,
// parallel search, skip-region rejection, dedup, and the 2 MiB chunk floor.
}
Key points:
next_record_startmust return a position where a record starts, not the delimiter itself.estimate_bytes_per_rowis called once on a sample. Simple newline-counting suffices for most formats.- Do not override
find_split_pointsunless you have a measured reason. The default handles everything including the 2 MiB chunk floor that prevents sub-MB collapse.
RecordParser design¶
The record parser turns byte chunks into field/value events fed to a ColumnarSink.
pub trait RecordParser: Send + Sync {
fn validate(&self, bytes: &[u8]) -> Result<()>;
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()>;
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:
fn validate(&self, bytes: &[u8]) -> Result<()> {
simdutf8::basic::from_utf8(bytes).map_err(rypipe_core::Error::Utf8)?;
Ok(())
}
This is cheap (SIMD-accelerated) and catches malformed input early.
parse_chunk¶
The main parsing loop. For each record: call sink.begin_row(), emit fields with sink.put_field(), then sink.end_row().
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, val) in self.header.iter().zip(line.split(',')) {
if sink.wants(col) { // skip dropped fields
sink.put_field(col, Value::Str(Cow::Borrowed(val)));
}
}
sink.end_row();
}
Ok(())
}
parse_chunk_generic¶
Override for devirtualized sink calls. When the engine knows the concrete sink type, it calls this instead, enabling inlining of begin_row/put_field/end_row:
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 for a measurable speedup on hot paths.
Push method hierarchy (cost model)¶
The ColumnarSink trait offers several ways to emit a field, from most to
least work per call:
| Method | Resolution work | When to use |
|---|---|---|
put_field_at(slot, value) |
None | After an expect_slot match; the slot is known |
put_field_resolved(name, value) |
None | After a successful resolve(name) |
resolve_and_put(name, value) |
One resolve | Convenience: resolve, then push if wanted |
put_field(name, value) |
Full resolve + push | Default path |
Raw-name variants (resolve_raw, resolve_and_put_raw) do the same jobs
for parsers that hold field names as &[u8] and want to avoid a UTF-8
conversion per field. put_row(&[(name, value)]) emits a whole row in one
call when the parser already has all fields collected.
Use the cheapest method your context allows.
The slot and scan fast paths¶
For maximum throughput, the sink exposes a cooperative protocol that lets a scanner skip work the engine does not need:
needs_value()returningfalseputs the scanner in locate-only mode: it reports field positions viaresolve()without extracting values. Schema discovery uses this.needs_resolve()returningfalsetells the scanner it can push values without resolving names at all.wants(name)lets the parser skip dropped or projected-out fields.wanted_mask()exposes the wanted set as a bitmask for very wide rows.expect_slot(ordinal)/record_slot(ordinal, slot, raw_name)/layout_broken(ordinal)/reset_child_ordinal()implement a positional slot protocol: when every row has the same field layout, the parser can match fields by ordinal and push withput_field_at, skipping name resolution entirely. If the layout changes mid-file, the parser reports it throughlayout_brokenand falls back to name-based pushes.row_satisfied()returningtruetells the parser the current row already fails or passes everything downstream needs, so it can byte-jump to the next row without extracting the remaining fields. This is the optimization behind crxml's fastest benchmark numbers.
All of these have default implementations, so a simple parser can ignore them and opt in one at a time.
Error handling¶
Malformed input¶
Return Err from parse_chunk to abort parsing. The engine propagates the error to the caller:
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
// If a record is malformed, return an error:
let value = extract_value(bytes)
.ok_or_else(|| rypipe_core::Error::Plan("malformed record".into()))?;
// ...
Ok(())
}
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 and produce a hard-to-debug Error::Parser.
Partial trailing rows¶
Chunks can start or end inside a row. If your parser reaches the end of the chunk mid-record, just return. The engine discards partial trailing rows automatically during normalize():
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
let text = std::str::from_utf8(bytes)?;
for line in text.lines() {
if line.is_empty() { continue; }
sink.begin_row();
// ... emit fields ...
sink.end_row();
}
// If the last line was partial, end_row() was never called for it.
// The engine's normalize() discards the incomplete row.
Ok(())
}
Recovery from bad chunks¶
In parallel mode, if one chunk fails, the entire parse fails. There is no per-chunk recovery. If you need partial results, use the bounded/streaming path and handle errors per-batch in the consumer.
Borrowing strings¶
If the input chunk is valid UTF-8, hand borrowed &str slices to the engine:
let text = std::str::from_utf8(bytes)?;
for line in text.lines() {
sink.begin_row();
sink.put_field("value", Value::Str(line));
sink.end_row();
}
The engine copies the string into its arena only when necessary. Borrowing avoids per-field allocations in the parser.
Sparse rows¶
If a field is missing, skip it entirely:
Do not emit Value::Null for every missing field. The engine null-fills missing columns at end_row(); emitting explicit nulls wastes work.
Respecting sink.wants¶
ColumnarSink::wants lets the parser skip fields that will be dropped:
For expensive extractions (deep XML paths, regex captures), this is a major win. Always check wants before doing work that the engine will discard.
Split regions and chunk floors¶
Two splitter-related pieces are easy to miss:
skip_regions()returns aSkipRegionFinder(withopeners(),closer_for(opener), and an optionalwindow()) that describes regions where a candidate boundary must be rejected, such as comments, CDATA sections, or quoted strings. The engine checks candidates within_skip_region, so a<Rowinside a comment never becomes a split point.plan_chunk_count(used by the defaultfind_split_points) keeps the chunk count in[threads, 1024]and enforcesMIN_CHUNK_BYTES(2 MiB), so tiny inputs do not collapse into per-row chunks.
Chunk-boundary rows¶
Chunks can start or end inside a row. A robust adapter does not need a
serial pre-pass to handle this: the splitter only emits boundaries at valid
row starts, and the engine discards the incomplete trailing row of each
chunk during TableBuilder::normalize(). Your parser just returns when it
runs out of complete records, as shown in
Partial trailing rows.
Summary¶
- Split cheaply with
memchr; defer full decoding. - Declare skip regions (comments, CDATA, quotes) so false boundaries are rejected.
- Borrow UTF-8 slices into the engine.
- Emit sparse rows and respect
sink.wants. - Opt into the sink fast paths (
put_field_atslots, locate-only scans,row_satisfied) when profiles justify them. - Handle trailing partial rows cleanly; the engine discards the incomplete row.
- Return
Errfor malformed input; never panic inparse_chunk. - Use the cheapest push method your context allows (
put_field_at>put_field_resolved>resolve_and_put>put_field).