Decoder API¶
decoder.rs defines the boundary between format-specific and format-agnostic
code. Adapters implement two traits (Splitter, RecordParser); the engine
implements the third (ColumnarSink). This page documents all three traits
in depth, including every method, its cost model, and how to squeeze maximum
performance from each.
See Writing adapters for the step-by-step guide to implementing these traits.
Splitter¶
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>;
}
next_record_start (required)¶
The only required method. Given a byte position, return where the next record starts at or after that position.
fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize> {
memchr::memchr(b'\n', &bytes[from..]).map(|r| from + r + 1)
}
The engine calls this at nominal offsets (bytes.len() * i / n) to find
record boundaries. Your implementation answers "where does the next record
start from here?" The engine handles deduplication, sorting, and chunk planning.
estimate_bytes_per_row (required)¶
Called once on a sample (first 64 KB) to estimate row size. Used by the bounded executor 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 (optional)¶
Returns a SkipRegionFinder for rejecting split points inside comments,
CDATA, quoted fields, or string literals. Default: None.
See Skip regions for the full interface.
find_split_points (default, do not override)¶
The default implementation handles everything:
- Early return
vec![0, bytes.len()]whenmax_chunks <= 1or input is empty plan_chunk_countcomputes target chunk count frommax_chunks(2 MiB target,16 × threadscap, 1024 hard max)- Nominal offsets at
bytes.len() * i / nwherenis the result of step 2 - Serially scan nominal offsets with
next_record_start(parallel parsing happens after ranges are built) - Skip-region rejection via
in_skip_region - Dedup, sort, prepend 0, append
bytes.len()
Override only with a measured reason. The 2 MiB value is a sizing heuristic; thread minimums can still produce smaller chunks. The default splitter caps its result at 1024 chunks.
RecordParser¶
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. Use for upfront checks like UTF-8 validation:
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: begin_row, put_field × N,
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 header.iter().zip(line.split(',')) {
sink.put_field(col, Value::Str(Cow::Borrowed(val)));
}
sink.end_row();
}
Ok(())
}
parse_chunk_generic¶
Override for devirtualized sink calls. The engine calls this when it knows
the concrete sink type, enabling inlining of begin_row/put_field/end_row.
ColumnarSink¶
pub trait ColumnarSink {
fn begin_row(&mut self);
fn put_field(&mut self, name: &str, value: Value<'_>);
fn end_row(&mut self);
fn finish(&mut self) -> Result<RecordBatch>;
// ... 17 more methods with defaults
}
Required methods¶
begin_row: Start a new row. Clears per-row state.put_field: Push a field value. Engine resolves name and stores.end_row: End the row. Null-fills missing, evaluates filter.finish: Finalize into Arrow RecordBatch.
Field resolution¶
wants(name):falseto signal the engine will drop this field.resolve(name): Map raw name to output column, orNoneif dropped.put_field_resolved(name, value): Push with pre-resolved name.resolve_and_put(name, value): Combined resolve + push.
Tier control¶
needs_value():false= locate-only (skip text extraction).needs_resolve():false= traverse-only (skip resolve).#[doc(hidden)]and experimental: intended for benchmarking/profiling harnesses, so do not rely on it in production adapters.row_rejected():true= filter rejected; scanner byte-jumps.
Projection¶
row_satisfied():true= all wanted columns present; byte-jump.wanted_mask(): Bitmask of wanted columns for O(1) membership test.reset_child_ordinal(): Reset ordinal after row-tag attributes.
Layout prediction¶
expect_slot(ordinal):(slot, raw_name)for memcmp fast path.put_field_at(slot, value): Direct slot push, no name resolution.record_slot(ordinal, slot, raw_name): Cache slot for next row.layout_broken(ordinal): Invalidate cached layout.
Batch¶
put_row(fields): Push a complete row in one call.
Raw-byte methods¶
resolve_raw(raw_name): Resolve a field name still in raw byte form. Default converts viafrom_utf8then delegates toresolve.resolve_and_put_raw(raw_name, value): Combined raw-name resolve + push. Default converts viafrom_utf8then delegates toresolve_and_put.
The raw variants exist for parsers that already hold the name as &[u8]
(scanner output, zero-copy slices). Overriding them lets you compare or
hash the bytes directly and skip the per-field UTF-8 validation. The
constraint: you only win if your format guarantees ASCII or UTF-8 names;
otherwise the default from_utf8 path is the safe choice.
Fast path hierarchy¶
| Method | Cost | When to use |
|---|---|---|
put_field_at(slot, value) |
~5 ns | After expect_slot match |
put_field_resolved(name, value) |
~10 ns | After resolve() |
resolve_and_put(name, value) |
~15 ns | Default |
put_field(name, value) |
~20 ns | Slowest, full resolution |
The cheaper methods are not free upgrades: each one shifts work onto the
parser. put_field_at is only valid after expect_slot has confirmed the
row layout, so the parser must detect layout changes and call
layout_broken. put_field_resolved requires the parser to call
resolve() itself and cache the result. put_field has no preconditions
at all. Start with put_field; only move down the table when profiling
shows name resolution is hot. See
Fast paths in order
for the full trade-off discussion.
Projection fast path¶
// Scanner checks after each field:
if sink.row_satisfied() {
let after = find_row_close(bytes, cur, row_tag, regions);
sink.end_row();
return Flow::At(after);
}
wanted_mask() provides the bitmask: (mask >> slot) & 1 == 1 means wanted.
Layout prediction fast path¶
expect_slot(ordinal) → Some((slot, expected))
memcmp(raw, expected) == 0 → put_field_at(slot, value)
memcmp(raw, expected) != 0 → layout_broken(ordinal)
Skips: attribute scan, UTF-8 decode, hash lookup. Cost: ~8 ns vs ~25 ns.
Predicate-first fast path¶
begin_row → [put_field × N] → end_row
│
▼
check predicate slot
├── Pass → direct mode
├── Fail → discard buffer
└── Undecided → continue buffering
Adaptive: if the predicate column is late (ordinal >= ncols * 4 / 5), disable
buffering. The check runs after the first committed row (earlier rows may not
have created all columns yet) and prefers the frozen schema or schema_order
column count over columns.len(), which is unreliable for sparse files.
Thread safety¶
ColumnarSink declares no Send/Sync supertraits; the concrete
TableBuilder is both Send and Sync. Each chunk gets its own instance.
In the parallel executor's fast path (no auto_dict, schema-consistent
chunks) each chunk's builder is exported as its own batch with no merge; the
merge path only runs for auto_dict upgrades or schema-inconsistent chunks.
Bounded and streaming executors emit batches incrementally via split_off
and never run a final merge.
Observer hooks¶
An optional plan.observer (RowObserver: Send + Sync) receives hooks from
TableBuilder during parsing: on_begin_row, on_put_field,
on_row_accepted, on_row_rejected, and on_chunk_finished. All default to
no-ops; implement only what you need. Two subtleties: values buffered by the
predicate-first path fire on_put_field only when the row is drained
(accepted), and on the late-predicate path values are pushed directly and
popped on reject, so on_put_field can fire for a row that is later
rejected (on_row_rejected still reports it). Hooks run on the parse hot
path from whichever thread parses the chunk, so keep them cheap.
Helper functions¶
split_points_to_ranges¶
Converts split points to non-empty ranges. Points must be sorted with 0
first and len last.
plan_chunk_count¶
Determines chunk count with a 2 MiB target, thread minimums, and a 1024 maximum. See Chunk planning.
in_skip_region¶
Bounded backward scan to check if a position is inside a skip region. See Skip regions.