The ColumnarSink Trait¶
ColumnarSink is the bridge between your parser and the engine. The parser
calls begin_row/put_field/end_row for each record; the sink accumulates
values into typed Arrow columns.
See Decoder API for how the engine implements this trait internally.
Method reference¶
Required¶
| Method | Signature | Purpose |
|---|---|---|
begin_row |
fn begin_row(&mut self) |
Start a new row. Clears per-row state. |
put_field |
fn put_field(&mut self, name: &str, value: Value<'_>) |
Push a field value. name is resolved via resolve_field. |
end_row |
fn end_row(&mut self) |
End the row. Null-fills missing columns, evaluates filter. |
finish |
fn finish(&mut self) -> Result<RecordBatch> |
Finalize into Arrow. Called once after all rows. |
Field resolution¶
| Method | Default | Purpose |
|---|---|---|
wants |
true |
Check if field should be kept (not dropped). |
resolve |
identity | Map raw name to output column name, or None if dropped. |
put_field_resolved |
delegates to put_field |
Push with pre-resolved name (skips rename lookup). |
resolve_and_put |
resolve then put_field_resolved | Combined resolve + push (single hash probe). |
Tier control¶
| Method | Default | Purpose |
|---|---|---|
needs_value |
true |
false = locate-only mode (skip text extraction). |
needs_resolve |
true |
false = traverse-only mode (skip resolve). |
row_rejected |
false |
true = filter rejected this row; scanner byte-jumps to row close. |
Projection¶
| Method | Default | Purpose |
|---|---|---|
row_satisfied |
false |
true = all wanted columns have values; scanner byte-jumps to row close. |
wanted_mask |
0 |
Bitmask of wanted columns. (mask >> slot) & 1 replaces per-field wants(). |
reset_child_ordinal |
no-op | Reset ordinal counter after row-tag attributes. |
Layout prediction¶
| Method | Default | Purpose |
|---|---|---|
expect_slot |
None |
(slot, raw_name_bytes) for ordinal. Skip attribute scan + hash on match. |
put_field_at |
no-op | Push directly to slot index (no name resolution). |
record_slot |
no-op | Cache slot resolution for subsequent rows. |
layout_broken |
no-op | Invalidate cached layout on mismatch. |
Batch¶
| Method | Default | Purpose |
|---|---|---|
put_row |
iterates put_field |
Push a complete row in one call. |
The default just loops over put_field, so there is nothing to gain unless
you override it. Override only if your parser naturally produces a whole
row at once (a decoded record struct, a fixed-width slot array) and can
push it without per-field branching; otherwise the per-field methods give
the engine more chances to short-circuit (wants, row_rejected).
Fast paths in order¶
The engine provides four push methods, from fastest to slowest. The speed
differences come from skipped lookups, and each skip moves a responsibility
onto your parser: the faster the method, the more preconditions you must
guarantee yourself. The slower methods exist because they are simpler and
less error-prone; a few nanoseconds per field only matter on very hot
paths, so start with put_field and move down this list only where
profiling justifies it.
1. put_field_at(slot, value): fastest¶
Direct slot push. No name resolution, no hash lookup. Used by the
expect_slot path after the layout is learned.
expect_slot(ordinal) → Some((slot, expected))
memcmp(raw, expected) == 0 → put_field_at(slot, value)
Cost: ~5 ns per field (column write + dirty bit set).
Use when: your format has a fixed field order and the parser sits on a hot path (millions of rows).
Constraint: slot numbers are only meaningful for one specific layout.
You must guard every push with the expect_slot memcmp and call
layout_broken(ordinal) the moment a row deviates (reordered, missing, or
extra fields), or you silently write values into the wrong columns. See
Layout prediction.
2. put_field_resolved(name, value): fast¶
Skips the rename lookup. Used when you've already called resolve().
Cost: ~10 ns per field (single HashMap lookup + column write).
Use when: extraction is expensive (entity decoding, base64, date
parsing), so you want to check resolve() once, skip unwanted fields, and
push without paying the rename/drop lookup a second time.
Constraint: the resolved name is only valid within the current row
state; do not cache it across rows or schema changes. And if extraction is
cheap, this gains nothing over resolve_and_put, since you paid for
resolve() anyway.
3. resolve_and_put(name, value): medium¶
Single resolve + push. Default implementation.
Cost: ~15 ns per field (HashMap lookup + column write).
Use when: you want the resolve() semantics (rename + drop honored,
unwanted fields skipped) in one call without managing the resolved name
yourself. This is the default for a reason: it is what put_field
degenerates to after resolution.
Constraint: still one hash lookup per field; on a fixed-layout hot loop, method 1 or 2 avoids it.
4. put_field(name, value): slowest¶
Full resolve + push. The engine calls resolve_field(name) which checks
rename map, then drop set, then returns the output name.
Cost: ~20 ns per field (two HashMap lookups + column write).
Use when: almost everywhere. No bookkeeping, no layout assumptions, no validity windows: pass the raw field name and the value and the engine does the right thing. At ~20 ns per field, a 1M-row, 10-column file spends about 0.2 s of total parse time here, usually a small fraction of actual parsing work.
Constraint: none, which is the point.
The projection fast path¶
Projection is the relational operation of selecting a subset of columns
from each row and discarding the rest. In rypipe it comes from three sources:
schema_order (the output contains exactly the listed columns),
.drop(...) / DropFields (named columns removed), and filter columns
(parsed for predicate evaluation, then projected out of the output).
Projection pushdown means moving that selection to the earliest possible point in the pipeline: the byte scanner itself. The theoretical win is that an unwanted column costs nothing at every stage it never reaches: no byte scanning, no UTF-8 decoding, no hash lookup, no allocation, no Arrow builder append. The engine exposes this as a chain of increasingly coarse signals:
wants(name)/resolve(): per-field; skip this field's value.wanted_mask(): per-row; a bitmask answeringwants()for all slots at once.row_satisfied(): per-row short-circuit; once every wanted column in the row has a value, the remaining fields can only be unwanted, so the scanner may skip them without even reading their names.
The third signal is what makes projection a scanner optimization rather than just a storage optimization. When a projection selects 3 of 11 columns and all 3 arrive by field 4, the scanner can byte-jump to the row close tag, skipping fields 5-11.
This is implemented in the scanner as:
// After each child element:
if sink.row_satisfied() {
let after = find_row_close(bytes, cur, row_tag, regions);
sink.end_row();
return Flow::At(after);
}
Composes with row_rejected() (filter rejection). A row can be
satisfied OR rejected; both short-circuit the scanner.
wanted_mask() provides the bitmask for this:
fn wanted_mask(&self) -> u64 {
// Bitmask of columns in the output schema
let mut mask = 0u64;
for name in &self.plan.schema_order {
if let Some(&idx) = self.field_index.get(name) {
if idx < 64 { mask |= 1u64 << idx; }
}
}
mask
}
The adapter checks (mask >> slot) & 1 == 1 instead of calling wants()
per field.
The layout prediction fast path¶
The generic per-field path does four pieces of work before a value lands in a column:
The observation behind this fast path: in most row-oriented formats the
layout is stable, meaning field number 3 of every row is the same
column. Once one row has been parsed generically, the mapping
ordinal → (slot, name) is known, and verifying it costs a single memcmp
instead of the whole pipeline. (An ordinal is the field's position within
the row, 0-based: first field is 0, second is 1, and so on.)
The learning side is automatic: every generic put_field call records
(slot, name_bytes) for its ordinal when no entry exists yet, so the first
row (and any re-learn after an invalidation) costs nothing extra. Your
adapter only opts into using the mapping. For each field:
- Ask
sink.expect_slot(ordinal).Nonemeans "nothing learned yet": take the generic path, which learns the entry as a side effect. Some((slot, expected)): memcmp the field's raw name bytes againstexpected.- Match: push with
sink.put_field_at(slot, value). No decode, no hash, no resolve. - Mismatch: call
sink.layout_broken(ordinal)to drop the stale entry, then fall through to the generic path, which re-learns the ordinal from this row's actual name.
A complete adapter loop:
fn parse_row(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
sink.begin_row();
let mut ordinal = 0u32;
for (raw_name, raw_value) in self.scan_fields(bytes) {
// Fast path: the engine cached a slot for this ordinal and the
// raw name bytes match. Skip decode + resolve entirely.
if let Some((slot, expected)) = sink.expect_slot(ordinal) {
if raw_name == expected {
sink.put_field_at(slot, Value::Str(Cow::Borrowed(raw_value)));
ordinal += 1;
continue;
}
// Layout changed (reordered, missing, or extra field).
// Invalidate so the generic path below re-learns the ordinal.
sink.layout_broken(ordinal);
}
// Generic path: decode + resolve + push. Also (re)learns the
// ordinal → (slot, name) mapping as a side effect.
let name = decode_name(raw_name);
if sink.wants(&name) {
sink.put_field(&name, Value::Str(Cow::Borrowed(raw_value)));
}
ordinal += 1;
}
sink.end_row();
Ok(())
}
Three rules keep this sound:
- Always memcmp before
put_field_at.put_field_atwrites blindly to a slot; without the comparison, a shifted layout puts values in the wrong columns silently. - Always
layout_broken(ordinal)on mismatch. Otherwise the stale entry either mismatches forever (you payexpect_slot+ memcmp per row for nothing) or, worse, matches a different field that happens to carry the old name at this ordinal and writes it to the wrong slot. - Never cache slot numbers yourself. Slots are only valid through
expect_slotfor the current layout; a drop, rename, or schema change invalidates them.
One subtlety: the engine stores the resolved (post-rename) name bytes,
so the memcmp only hits when the raw bytes are already the final name. A
field whose name contains an entity (&) or escapes never matches in
raw form and always falls through to the generic path. That is correct,
just not fast.
Cost comparison:
- Generic path: ~25 ns (find_attr_value + decode_attr + resolve + put_field)
- Fast path: ~8 ns (memcmp + put_field_at)
When it helps: formats with stable field order across rows (CSV columns, XML attributes, JSONL keys). The first row learns the layout; subsequent rows skip the expensive resolution.
When it doesn't help: formats where field order changes between rows (every memcmp fails, so you pay for the attempt and the generic path), or where field names contain entities that need decoding before comparison.
The predicate-first fast path¶
A filter creates a dilemma for the engine: a rejected row's values are wasted work, but you cannot know whether a row passes until its predicate column arrives, and that column may sit anywhere in the row. The two naive strategies both lose:
- Push everything, delete on reject: values are materialized into Arrow builders before the verdict; a rejected row pays full column writes plus a per-column pop to remove them.
- Buffer the whole row, decide at
end_row: rejected rows cost nothing in the columns, but every value of every passing row pays a buffer copy first, even when the filter was decidable after field 2.
Predicate-first is the middle ground: buffer only until the predicate
resolves, then commit to one of the two strategies for the rest of the
row. Fields arrive as (slot, value) pairs in a per-row buffer, and after
each field the engine re-evaluates the predicate using the values seen so
far:
begin_row → [buffered put_field × N] → end_row
│
▼ after each field
predicate state
├── Pass → drain buffer into columns, switch to direct
│ mode: remaining fields push unbuffered
├── Fail → row_rejected() = true; end_row discards the
│ buffer (zero Arrow writes for this row)
└── Undecided → keep buffering
A Pass mid-row means the buffer drains once and the rest of the row costs the same as an unfiltered parse. A Fail mid-row means nothing was ever written to a column, and the scanner can stop reading the row immediately. A rejected row therefore costs only its buffered fields up to the predicate column; a passing row pays the buffer only up to the predicate column too.
Your adapter's part is one check per field: after pushing, ask
sink.row_rejected() and byte-jump out of the row when the filter has
already decided against it:
sink.begin_row();
for (raw_name, raw_value) in self.scan_fields(bytes) {
let name = decode_name(raw_name);
if sink.wants(&name) {
sink.put_field(&name, Value::Str(Cow::Borrowed(raw_value)));
}
// Filter decided against this row mid-row: skip to the row close,
// end_row throws the buffered fields away.
if sink.row_rejected() {
let after = find_row_close(bytes, cur, row_tag, regions);
sink.end_row();
return Ok(Flow::At(after));
}
}
sink.end_row();
Two things worth knowing when combining this with other features:
wants()returnstruefor predicate columns even when they are projected out of the output, because the engine needs their values to evaluate the filter. They are parsed, used, and dropped atfinish().- Buffering is why
Cow::Borrowedvalues must borrow from the chunk, not from a temporary: the engine may hold a value past the point where your per-field temporary is gone (see Parser: Cow::Borrowed).
Adaptive strategy: buffering only pays when the predicate resolves
early. The engine records the predicate column's ordinal on the first row;
if it appears late (beyond 4/5 of the columns), buffering whole rows is a
net loss, so from row 2 onward the engine switches to direct push +
pop-on-reject: values go straight into the columns, and a row that fails at
end_row has its values popped back out. The adapter code above is
unchanged; row_rejected() simply stays false mid-row in that mode.
Example: minimal sink¶
struct CountingSink {
rows: usize,
fields: usize,
}
impl ColumnarSink for CountingSink {
fn begin_row(&mut self) {}
fn put_field(&mut self, _name: &str, _value: Value<'_>) { self.fields += 1; }
fn end_row(&mut self) { self.rows += 1; }
fn finish(&mut self) -> Result<RecordBatch> {
Ok(RecordBatch::new_empty(Arc::new(Schema::empty())))
}
}
Example: profiling sink (locate-only)¶
struct LocateOnlySink {
row_count: usize,
field_count: usize,
plan: ExecutionPlan,
}
impl ColumnarSink for LocateOnlySink {
fn begin_row(&mut self) {}
fn put_field(&mut self, name: &str, _value: Value<'_>) {
self.field_count += 1;
// Only resolve, don't store
let _ = self.plan.resolve_field(name);
}
fn end_row(&mut self) { self.row_count += 1; }
fn wants(&self, _name: &str) -> bool { true }
fn needs_value(&self) -> bool { false } // Skip text extraction
fn resolve<'a>(&'a self, name: &'a str) -> Option<&'a str> {
self.plan.resolve_field(name)
}
fn finish(&mut self) -> Result<RecordBatch> {
Ok(RecordBatch::new_empty(Arc::new(Schema::empty())))
}
}
Thread safety¶
ColumnarSink is Send but not Sync. Each chunk gets its own sink
instance via begin_row/end_row lifecycle. The engine creates one
TableBuilder per chunk and merges them after all chunks complete.
Note
The begin_row/end_row lifecycle means you can safely use non-atomic
mutable state in your sink (e.g., counters, buffers). The engine never
shares a sink instance across threads: it clones or creates per-chunk.
Warning
If you implement a custom ColumnarSink and share it across threads via
&dyn ColumnarSink, you will hit data races. Always let the engine manage
sink instances: one per chunk, never shared.
Build and test¶
Verify the projection contract: with a plan that drops every field except
name, a correct wants() implementation lets the parser skip all other
fields and the finished batch has exactly one column:
$ cargo test sink_wants
running 1 test
test tests::sink_wants_skips_dropped_fields ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.00s
The test builds a plan with .drop("age").drop("active"), parses a
two-row sample, and asserts batch.num_columns() == 1 and the remaining
column is name. If your sink ignores wants(), all three columns
appear and this test fails.
What the end user sees¶
The sink and its fast paths are engine internals. The user calls
to_arrow() once and receives a finished pyarrow.Table; the per-field
method hierarchy (put_field_at, put_field_resolved, ...) only shows up
as throughput: