Engine: TableBuilder¶
TableBuilder (crates/rypipe-core/src/engine.rs:16) is the central structure. It implements ColumnarSink and is the only production sink that most adapters need.
Structure¶
pub struct TableBuilder {
pub(crate) columns: Vec<ColumnBuilder>,
pub(crate) field_index: HashMap<String, usize>,
pub(crate) column_order: Vec<String>,
pub(crate) row_count: usize,
pub(crate) estimated_rows: usize,
pub(crate) plan: ExecutionPlan,
pub(crate) row_dirty: Vec<bool>,
}
Why this shape:
-
columns: Vec<ColumnBuilder>holds dense column storage. Indexingcolumns[idx]is a bounds checked array access, not a hash probe. This replaces the earlierHashMap<String, ColumnBuilder>that required two hashes per field (one inensure_column, one inget_mut). See Optimizations for the before and after. -
field_index: HashMap<String, usize>maps resolved column name toVecindex. One hash per field in steady state.FxHashMap(rustc_hash) is used for speed on short strings. -
column_order: Vec<String>records first appearance order, then reordered byschema_orderinsort_columns. It is independent ofVecorder, which is insertion order.schema_insert_indexcomputes the insertion position for a new column based on the desired output order. -
row_count: usizeis the number of committed rows. A row is not counted untilfinish_rowsucceeds (including filter). -
row_dirty: Vec<bool>has the same length ascolumns.row_dirty[i]is true if columnireceived a value in the current uncommitted row. It letsfinish_rownull fill only missing columns and avoids a per columnwhile len < targetcheck for touched columns. -
estimated_rows: usizeandplan: ExecutionPlanare carried fromPipeline::with_planand used for capacity hints and per row decisions.
Constructors (new, with_capacity, with_plan) all initialize the three Vectors and the map as empty.
Helpers¶
-
get_column(&self, name: &str) -> Option<&ColumnBuilder>andget_column_mut(&mut self, name: &str) -> Option<&mut ColumnBuilder>are the single lookup path:field_index.get(name).map(|&i| &self.columns[i]). Tests andmerge.rsuse these instead of HashMapget. -
take_column(&mut self, name: &str) -> Option<ColumnBuilder>removes and returns ownership. It doesfield_index.remove(name)to getidx, thencolumns.swap_remove(idx)(orpopif last). If the removed index was not the last, the element that was atlastmoves toidx; the code finds its key infield_index(value== old_last) and repoints it toidx. It also keepsrow_dirtyin sync withswap_remove(orpop). This is used only bymerge::extendwhereotheris consumed.
Core row protocol¶
Adapters call begin_row, put_field (or put_field_resolved), end_row in a loop. TableBuilder implements these as:
-
begin_rowdoes nothing. Row boundaries are tracked byrow_countandrow_dirty. -
push_fieldresolves the raw name (plan.field_mapthenplan.drop_fields) and delegates topush_field_resolved. Fast path: if both maps are empty, it usesnamedirectly and avoids allocation and hashing inresolve_field. -
push_field_resolvedis the hot path (see Optimizations for the single lookup version). It callsensure_column_idx(resolved)to getidx, marksrow_dirty[idx] = true, then handles last write wins: ifcolumns[idx].len() > row_count, the column already has a value for this row (duplicate field in the same row), so it pops before pushing the new value. Thenpush_valueis called on the builder. -
ensure_column_idx(&mut self, name: &str) -> usizedoes one hash lookup. Iffield_index.get(name)exists, it returns immediately. Otherwise it creates aColumnBuilder::with_capacity(est, &col_type)whereest = estimated_rows.max(64)andcol_type = plan.column_type(name), backfillsrow_countnulls (for _ in 0..row_count { b.push(None) }), pushes tocolumns, inserts intofield_index, pushesfalsetorow_dirty, and inserts intocolumn_orderatschema_insert_index(name). -
finish_rowis where the dirty optimization matters (2C-S1). Instead of looping over all columns and doingwhile b.len() < target { b.push(None) }, it does:
for (i, b) in self.columns.iter_mut().enumerate() {
if !self.row_dirty[i] {
b.push(None);
} else {
self.row_dirty[i] = false;
}
}
if let Some(ref filter) = self.plan.filter {
if !filter.check(&self.columns, &self.field_index, self.row_count, &self.plan) {
for b in &mut self.columns { b.pop(); }
return;
}
}
self.row_count += 1;
Only missing columns get a push(None); touched columns are just cleared for the next row. For 10 columns where 8 are present each row, this saves 80% of the null fill pushes and the associated len checks. The loop still iterates over columns.len() to check the bool, but the bool check is a single byte load versus a len load plus branch and push.
Filter is evaluated per row via FilterPredicate::check with (&columns, &field_index, row_index, &plan). If it fails, each column is popped (undoing the row) and row_count is not advanced. Dirty was already cleared, so the next row starts clean. For And/Or/Not trees, check short circuits.
Other methods¶
-
resetclearscolumns,field_index,column_order,row_dirty, and resetsrow_countto zero while keepingplanandestimated_rows. -
normalizetruncates any column withlen > row_count(partial row from a truncated chunk) and clearsrow_dirtyto all false. Idempotent. -
auto_dict_upgradeiterates&mut self.columnsand callstry_upgrade_to_dict(512, max_ratio, max_size)whenplan.auto_dictis true. Threshold defaults are 0.05 ratio and 256 entries. It is called fromfinishbefore sorting. -
sort_columnsreorderscolumn_orderbyschema_orderrank. It does not reordercolumnsorfield_index; those stay insertion ordered and are looked up by name. Only the output order changes. -
schema_insert_index(&self, name: &str) -> usizecomputes where a new column should be inserted intocolumn_orderto respectschema_order. Ifschema_orderis empty, it returnscolumn_order.len()(append). Otherwise it finds the position ofnameinschema_orderand returns the position of the first existing column that appears later in that order. -
finish(&mut self) -> Result<RecordBatch>(alsoColumnarSink::finish) doesnormalize, early return withRecordBatch::new_emptyifcolumn_orderis empty,auto_dict_upgrade,sort_columns, then buildsfieldsandarraysby iteratingcolumn_orderand looking up each builder viaget_column, callingarrow_datatypeandto_arrow_array. It createsArc::new(Schema::new(fields))andRecordBatch::try_new.
ColumnarSink implementation¶
impl ColumnarSink for TableBuilder {
fn begin_row(&mut self) {}
fn put_field(&mut self, name: &str, value: Value<'_>) { self.push_field(name, value) }
fn end_row(&mut self) { self.finish_row() }
fn wants(&self, name: &str) -> bool { self.resolve(name).is_some() }
fn resolve<'a>(&'a self, name: &'a str) -> Option<&'a str> { self.plan.resolve_field(name) }
fn put_field_resolved(&mut self, resolved_name: &str, value: Value<'_>) { self.push_field_resolved(resolved_name, value) }
fn finish(&mut self) -> Result<RecordBatch> { ... }
}
wants now delegates to resolve, so an adapter that does if sink.wants(k) { sink.put_field(k, v) } pays one resolve_field hash. The faster pattern is if let Some(r) = sink.resolve(k) { /* expensive decode */ sink.put_field_resolved(r, v) } which pays one hash total (see Decoder and Optimizations).
Invariants¶
columns.len() == field_index.len() == row_dirty.len()always.columns.len() == column_order.len()after each successfulfinish_roworextend, but during a rowcolumnsmay be larger thanrow_count+1beforefinish_rowcompletes.row_dirty[i]is true exactly whencolumns[i].len() == row_count + 1and the column was touched this row; afterfinish_rowall entries are false.take_columnkeeps the three vectors in sync via swap remove patching.
Tests¶
Inside engine::tests, LineParser plus LineSplitter exercise the same put_field path used by real adapters. Tests cover extend (no duplicates, multi chunk same as single, ragged late debut), last write wins, rename, drop, filter eq/ne/missing, typed columns, dictionary, and apply_compare_filter.