Columnar storage¶
This page documents crates/rypipe-core/src/columnar.rs in full. It is the storage layer that makes TableBuilder fast and Arrow export cheap.
StrColumn¶
This is exactly the Arrow StringArray layout (offsets plus bytes plus null bitmap) without per cell String allocation.
-
data: Vec<u8>is one contiguous arena. Every string's bytes are appended sequentially. -
offsets: Vec<i32>haslen + 1entries.offsets[i] .. offsets[i+1]is the byte range for valuei. It is initialized with[0]sopushcan compute the next offset asdata.len(). -
validity: Vec<bool>marks null vs present.truemeans present;falsemeans null (the buffer contains no bytes for that slot, but offsets still advance by 0).
Operations:
-
with_capacity(cap)preallocatesoffsetswithcap + 1anddatawithcap * 16(heuristic 16 bytes per string). This matchesTableBuilder::estimated_rows. -
push(v: Option<&str>)extendsdataifSome, pushesdata.len()tooffsets, and pushesis_sometovalidity. No allocation per cell beyond the arena growth. -
popundoes the last push: popsvalidity, popsoffsets, truncatesdatato the last offset. -
lenisvalidity.len(). -
get(i: usize) -> Option<&str>checks validity, slicesdata[offsets[i] .. offsets[i+1]], and doesfrom_utf8(the slice is known UTF-8 fromvalidate, but the check is kept for safety). -
append(&mut self, other: &StrColumn)merges another column by base shifting offsets:base = self.data.len() as i32, thenself.offsets.extend(other.offsets[1..].iter().map(|o| o + base)). This is O(n) in offsets, not in bytes. -
to_arrow() -> Result<ArrayRef>builds an ArrowStringArrayby wrapping the three buffers withOffsetBuffer,Buffer, andNullBuffer. When all validity are true,nullsisNone. This is a block copy of two buffers, not per cell.
ColumnBuilder¶
pub(crate) enum ColumnBuilder {
String(StrColumn),
Int64(Vec<Option<i64>>),
Float64(Vec<Option<f64>>),
Boolean(Vec<Option<bool>>),
Date32(Vec<Option<i32>>),
Timestamp(TimeUnit, Vec<Option<i64>>),
Dictionary { codes: Vec<Option<i32>>, dict: Vec<String>, index: HashMap<String, i32> },
}
Each variant stores a dense Vec<Option<T>> (or StrColumn for strings). There is exactly one builder per column, created by ExecutionPlan::column_type at first use.
StringisStrColumn.Int64,Float64,BooleanareVec<Option<T>>withlexical::parsefor string inputs.Date32(Vec<Option<i32>>)stores days since epoch. Parsing useschrono::NaiveDate::parse_from_str("%Y-%m-%d").Timestamp(TimeUnit, Vec<Option<i64>>)stores raw integers in the column'sTimeUnit(Second, Millisecond, Microsecond, Nanosecond). Parsing tries"%Y-%m-%dT%H:%M:%S%.f", then" %H:%M:%S%.f", then bare"%Y-%m-%d"as midnight. Timezone handling is left to adapters that emitValue::Timestampdirectly.Dictionary { codes, dict, index }storescodes: Vec<Option<i32>>plusdict: Vec<String>(id to string) andindex: HashMap<String,i32>(string to id). This is the write path forFieldType::Dictionaryand for auto dict upgrade.
Variant keys for unification (10 strings):
string,int64,float64,boolean,date32,timestamp[s],timestamp[ms],timestamp[us],timestamp[ns],dictionary
variant_key(&self) -> &'static str returns the key. Timestamp units are distinguished so merging timestamp[s] with timestamp[ms] is an error rather than silent promotion.
Push paths¶
push_value(&mut self, value: Value<'_>) is called for every field of every row. It handles typed Value variants:
Value::NullbecomesNone.Value::Str(s)callspush_str(Some(s)), which parses according to column type:lexical::parsefor numbers,parse::<bool>for booleans,parse_date32/parse_timestampfor temporals. Unparseable becomesNone.Value::Int64(i)intoInt64is native, intoFloat64widensi as f64, intoStringstringifies, intoDictionaryencodes viadict_code.- Similarly for
Float64,Bool,Date32,Timestamp. Cross type mismatches (for exampleBoolintoInt64) becomeNone.
push(&mut self, value: Option<String>) and push_str(&mut self, value: Option<&str>) are the string entry points. push_str avoids allocation for typed columns (it parses and discards the string). Both handle Dictionary by calling dict_code.
dict_code(dict: &mut Vec<String>, index: &mut HashMap<String,i32>, v: &str) -> i32 does if let Some(&code) = index.get(v) { return code } else dict.push(v.to_owned()) and insert. Average O(1).
Auto dictionary¶
try_upgrade_to_dict(&mut self, min_rows: usize, max_ratio: f64, max_size: usize) upgrades a String builder to Dictionary when cardinality is low. Steps:
- Only
Stringbuilders; others are no ops. - If
len < min_rows(512 inTableBuilder::auto_dict_upgrade), leave asString. - Count distinct via
FxHashSet<&str>overiter().flatten()(skipping nulls). - Compute cap:
ratio_cap = ((len as f64 * max_ratio) as usize).max(16).min(max_size). Floor of 16 lets tiny columns upgrade; cap respectsdict_threshold(default 0.05) anddict_max_size(default 256). - If distinct > cap, leave as
String. - Otherwise build
dict,index,codesfrom the oldStrColumnviadict_code.
Called after each chunk parse when plan.auto_dict is true, and after merge via TableBuilder::auto_dict_upgrade which respects plan.dict_threshold and plan.dict_max_size.
Merging and promotion¶
extend_owned(&mut self, other: ColumnBuilder) -> Result<()> merges other into self by consuming other. Both must be the same variant (after promotion). Cases:
StringviaStrColumn::append(base shift)Int64/Float64/Boolean/Date32/TimestampviaVec::appendTimestampchecksunit_a == unit_belseError::MergeDictionaryremapsb's dictionary intoa's viadict_codeper value, then translates codes viaremap[idx]
unify_variants(a: &str, b: &str) -> Option<&'static str> reconciles two variant keys:
- same → same
int64plusfloat64→float64stringplusdictionary→dictionary- otherwise
None(irreconcilable)
promote_to_variant(&mut self, target: &'static str) -> Result<()> mutates in place:
Int64toFloat64viastd::mem::takethenmap(|o| o.map(|n| n as f64))StringtoDictionaryvia taking theStrColumn, buildingdict/index/codes- Same key is a no op
- Any other target returns
Error::Merge
Used in merge::extend and merge::engines_to_record_batches before extend_owned.
Arrow export¶
arrow_datatype(&self) -> DataType maps each variant to Arrow type: Utf8, Int64, Float64, Boolean, Date32, Timestamp(unit, None), Dictionary(Int32, Utf8).
to_arrow_array(&self) -> Result<ArrayRef> builds the native array:
StringviaStrColumn::to_arrowInt64/Float64/Boolean/Date32viaiter().copied().collect::<Array>()Timestamp(unit, v)viacollect::<PrimitiveArray<TimestampXType>>per unitDictionaryviaInt32Arraykeys plusStringArrayvalues intoDictionaryArray::<Int32Type>::try_new
Typed value view¶
TypedValue<'a> is a borrowed view for filter evaluation:
pub(crate) enum TypedValue<'a> { Str(&'a str), Int64(i64), Float64(f64), Bool(bool), Date32(i32), Timestamp(i64) }
get_typed_value(&self, index: usize) -> Option<TypedValue<'_>> borrows directly from storage (dictionary decodes to Str via dict lookup). get_filter_value formats as String for Equal/NotEqual (dates via format_date32, timestamps via format_timestamp).
This enum lets FilterPredicate::Compare run natively per row with numeric promotion, without allocation.