Schema Architecture¶
This page documents the internal architecture of schema handling in rypipe.
It covers FrozenSchema, DiscoveryOpts, the schema cache, ensure_schema,
and the fast/merge export paths.
For the adapter-author guide, see Schema.
Overview¶
Schema in rypipe answers three questions:
- Which columns are extracted? (
schema_orderprojection, or all discovered columns) - What type is each column? (
field_typesor defaultString) - What order are they in? (
schema_orderor first-appearance)
When schema_order is declared it acts as a projection + order: the
output contains exactly the listed columns, in the listed order; unlisted
fields are rejected by plan.resolve_field during parsing (so
ColumnarSink::wants/resolve return false/None and adapters skip them
entirely); listed columns absent from the data are null-filled at finish.
Fields referenced by the row filter are still materialized internally so
predicates evaluate correctly, but they are projected out of the final
batch. A column listed in both schema_order and drop_fields is dropped
(drop_fields wins).
The engine resolves these questions before parsing starts, builds an immutable
FrozenSchema, and shares it across all workers. This guarantees that every
batch has identical columns, types, and order.
Data flow¶
User provides schema_order + field_types
|
v
ExecutionPlan (compiled, shared via Arc)
|
+---> FrozenSchema::from_plan (explicit) -----> ensure_schema
| |
+---> FrozenSchema::from_discovered (auto) ----> ensure_schema
|
v
TableBuilder per chunk
|
v
sort_columns (finish)
|
v
RecordBatch export
FrozenSchema¶
FrozenSchema is the engine's immutable representation of the output column
layout. It is defined in crates/rypipe-core/src/schema.rs.
Structure¶
pub struct FrozenSchema {
/// Output column names in final order.
names: Vec<Arc<str>>,
/// Raw input field name -> output slot index.
/// None = dropped by plan.
index: FxHashMap<Box<str>, Option<u32>>,
/// Output column types, parallel to names.
types: Vec<FieldType>,
/// true = full scan, false = sampled.
exact: bool,
}
Key design decisions:
namesusesArc<str>: shared across workers without cloning.indexmaps raw names toOption<u32>: collapses rename, drop, and lookup into a single hash probe.typesis parallel tonames: type for columniistypes[i].exacttracks discovery method: affects error messages and cache behavior.
Construction: explicit schema¶
When schema_order is provided, FrozenSchema::from_plan builds the schema
directly from the declared names:
pub fn from_plan(names: &[&str], plan: &ExecutionPlan) -> Self {
let mut index = FxHashMap::default();
let mut types = Vec::with_capacity(names.len());
for (slot, &name) in names.iter().enumerate() {
let ty = plan.column_type(name);
types.push(ty);
index.insert(Box::from(name), Some(slot as u32));
}
FrozenSchema {
names: names.iter().map(|n| Arc::from(*n)).collect(),
index,
types,
exact: true,
}
}
Properties:
- O(n) construction where n = number of columns.
- No I/O: the schema is pure computation.
- Exact: all columns are known, no sampling uncertainty.
exact = true: enables the "unknown field is an error" behavior.
Construction: discovered schema¶
When schema_order is empty, the engine discovers column names from the file.
FrozenSchema::from_discovered applies the plan's renames and drops:
pub fn from_discovered(names_in_order: &[String], plan: &ExecutionPlan) -> Self {
let mut index = FxHashMap::default();
let mut out_names = Vec::new();
let mut types = Vec::new();
for name in names_in_order {
let resolved = plan.resolve_field(name);
match resolved {
Some(resolved_name) => {
if plan.drop_fields.contains(resolved_name) {
index.insert(Box::from(name.as_str()), None);
continue;
}
let slot = out_names.len() as u32;
let ty = plan.column_type(resolved_name);
index.insert(Box::from(name.as_str()), Some(slot));
out_names.push(Arc::from(resolved_name));
types.push(ty);
}
None => {
index.insert(Box::from(name.as_str()), None);
}
}
}
FrozenSchema {
names: out_names,
index,
types,
exact: false,
}
}
Properties:
- Applies renames:
field_mapentries are resolved during construction. - Applies drops: dropped fields get
Nonein the index. exact = false: sampled discovery may miss rare columns.- Order: follows discovery order (file order, then document order).
Construction: partial explicit schema¶
FrozenSchema::from_partial_plan is a third mode: like from_plan, but
with exact = false. Declared columns keep their specified order; fields
in the file that are not listed are appended in discovery order instead of
causing an error.
Resolution: the hot path¶
FrozenSchema::resolve maps a raw field name to its output slot:
#[inline]
pub fn resolve(&self, raw_name: &str) -> Option<u32> {
self.index.get(raw_name).copied().flatten()
}
resolve is used at schema boundaries (and in tests). The per-field hot
path inside TableBuilder resolves names via plan.resolve_field plus a
field_index hash probe (resolve_and_slot), and after the first row of a
stable layout mostly bypasses hashing entirely via the expect_slot
ordinal/memcmp fast path (put_field_at). An FxHashMap probe costs
~15 cycles (one hash, one comparison).
The Option<u32> return value:
Some(slot): field maps to output columnslotNone: field is dropped (parser should skip it)
DiscoveryOpts¶
DiscoveryOpts controls how schema discovery works for files without explicit
schema.
Structure¶
pub struct DiscoveryOpts {
/// Files smaller than this are fully scanned (cheap, exact).
pub full_scan_threshold: u64,
/// Number of strided windows for large files.
pub windows: usize,
/// Bytes per window.
pub window_bytes: usize,
/// Whether to always scan the tail of the file to catch
/// late-appearing columns.
pub always_scan_tail: bool,
}
Default values¶
impl Default for DiscoveryOpts {
fn default() -> Self {
Self {
full_scan_threshold: 128 * 1024 * 1024, // 128 MiB
windows: 16,
window_bytes: 2 * 1024 * 1024, // 2 MiB
always_scan_tail: true,
}
}
}
Discovery strategy¶
Small files (<=128 MiB):
- Full scan of the entire file
- Exact: every column is found
- Cost: one I/O pass + parsing
Large files (>128 MiB): window count and size scale with file size
(dynamic_window_count / dynamic_window_size):
- 128 MiB to 1 GiB: 16 windows of 2 MiB (32 MiB total)
- 1 GiB to 10 GiB: 32 windows of 4 MiB (128 MiB total)
- 10 GiB and up: 64 windows of 4 MiB (256 MiB total)
- Windows are strided evenly through the file
- Columns found in any window are included
- Cost: ~5.3 ms for a 533 MB file on 16 threads (parallel sampling)
Accuracy¶
Sampled discovery captures columns that appear in at least one window. For typical CR XML exports:
- 100% of columns appear in the first 2 MiB (header section)
FieldG(30% sparse) is found in ~94% of samplesText21(1% sparse) is found in ~94% of samples- A column appearing only in the last 1% of the file would be missed
When a missed column is encountered at parse time, the engine raises
Error::Merge with the message:
unknown field "LateColumn" not in frozen schema (10 columns, exact=false);
pass schema=[...] with full column list or use full-scan discovery
When discovery is unreliable¶
For formats where schema discovery is unreliable (extremely sparse or
heterogeneous data), pass an explicit schema from the API layer. An
explicit schema pre-declares the columns: listed columns are parsed into
their declared field_types in the given order, columns absent from the
data come out null-filled, and unlisted fields are skipped at parse
level. Fields referenced by filter are still parsed for predicate
evaluation, then projected out of the output.
Schema cache¶
For batch workloads (many files with the same layout), the engine caches discovered schemas to avoid redundant discovery.
Cache structure¶
pub static SCHEMA_CACHE: LazyLock<RwLock<FxHashMap<SchemaCacheKey, SchemaCacheValue>>>
= LazyLock::new(|| RwLock::new(FxHashMap::default()));
type SchemaCacheKey = (u64, u64); // (file_len, sample_hash)
type SchemaCacheValue = Arc<Vec<String>>; // discovered column names
Layout signature¶
layout_signature computes a cheap key for caching:
pub fn layout_signature(bytes: &[u8], opts: &DiscoveryOpts) -> (u64, u64) {
let mut hasher = FxHasher::default();
if (bytes.len() as u64) < opts.full_scan_threshold {
// Small file: hash the entire content
bytes.hash(&mut hasher);
} else {
// Large file: hash 4 strided windows
let n = 4usize;
let w = opts.window_bytes.min(bytes.len());
for i in 0..n {
let start = (bytes.len() - w) * i / n.max(1);
bytes[start..start + w].hash(&mut hasher);
}
}
(bytes.len() as u64, hasher.finish())
}
Properties:
- Fast: ~1 us for small files, ~5 us for large files.
- Collision-resistant: 4-window hash for large files.
- Stable: same layout always produces the same key.
Cache operations¶
Insert (insert_schema_cache):
pub fn insert_schema_cache(sig: (u64, u64), order: Arc<Vec<String>>) {
let mut cache = SCHEMA_CACHE.write().unwrap_or_else(|e| e.into_inner());
if cache.len() >= SCHEMA_CACHE_CAP {
if let Some(oldest) = cache.keys().next().copied() {
cache.remove(&oldest);
}
}
cache.insert(sig, order);
}
- Evicts arbitrary entry when at capacity (128).
FxHashMapdoes not track insertion order; this is acceptable for a bounded cache.
Lookup: done via SCHEMA_CACHE.read() in the discovery path
Clear (clear_schema_cache):
pub fn clear_schema_cache() {
SCHEMA_CACHE.write().unwrap().clear();
SCHEMA_CACHE_HITS.store(0, Ordering::Relaxed);
SCHEMA_CACHE_MISSES.store(0, Ordering::Relaxed);
}
Cache statistics¶
pub static SCHEMA_CACHE_HITS: AtomicU64 = AtomicU64::new(0);
pub static SCHEMA_CACHE_MISSES: AtomicU64 = AtomicU64::new(0);
pub fn schema_cache_stats() -> (u64, u64) {
(
SCHEMA_CACHE_HITS.load(Ordering::Relaxed),
SCHEMA_CACHE_MISSES.load(Ordering::Relaxed),
)
}
For 1,000 files with the same layout:
- First file: miss (5.3 ms discovery + cache insert).
- Files 2-1000: hit (cache lookup ~1 us each).
- Total: 5.3 ms + 1 ms = 6.3 ms (vs 5.3 s without cache).
ensure_schema¶
ensure_schema is called by each worker before parsing a chunk. It guarantees
that all columns from the FrozenSchema exist in the TableBuilder.
Algorithm¶
pub fn ensure_schema(&mut self, schema: &FrozenSchema) -> Result<()> {
for (slot, name) in schema.column_names().iter().enumerate() {
if self.field_index.contains_key(name) {
continue; // already present
}
let ty = schema.column_types()[slot].clone();
let col = ColumnBuilder::with_capacity(self.estimated_rows, &ty);
self.columns.push(col);
self.field_index.insert(name.to_string(), self.columns.len() - 1);
self.column_order.push(name.to_string());
}
// Resize row_dirty bitmask to cover all columns.
let words = self.columns.len().div_ceil(64);
self.row_dirty.resize(words, 0);
// Freeze: any later field not in this schema is an unknown field
// and must hard-error on finish().
self.frozen = Some(Arc::new(schema.clone()));
Ok(())
}
Steps:
- Iterate over all columns in the schema.
- For each column not already in the builder, create a capacity-reserved
typed column builder for the correct
FieldType. No rows are pre-filled:ensure_schemaruns before parsing, when the builder is empty. - Freeze the schema on the builder so unknown fields hard-error at finish.
- After this call, the builder has all declared columns.
Why pre-declare the columns?¶
Consider a file where column "D" appears only in rows 500-1000. Without
ensure_schema:
- Chunk 1 (rows 0-499): no column "D"
- Chunk 2 (rows 500-999): has column "D"
- At merge time: column "D" is missing from chunk 1, causing schema mismatch
With ensure_schema:
- Chunk 1: column "D" exists as an empty builder; each row that lacks it is
null-filled per row by
finish_row's dirty-mask pass - Chunk 2: column "D" has values
- At export time: batches have identical schemas
Performance¶
ensure_schema is called once per chunk, not per row. For 16 chunks, it
adds ~0.1 ms total (negligible vs parse time).
sort_columns¶
sort_columns reorders the internal column list to match schema_order.
Note: when schema_order is non-empty, finish() takes the projection
path instead: it emits exactly the declared columns in the declared order
(schema columns are pre-declared in with_plan, and any still missing are
created null-filled), so sort_columns is effectively vestigial for the
projection path.
Algorithm¶
pub fn sort_columns(&mut self) {
if self.plan.schema_order.is_empty() {
return;
}
let order = &self.plan.schema_order;
let rank =
|name: &String| order.iter().position(|n| n == name).unwrap_or(usize::MAX);
self.column_order.sort_by_key(rank);
}
Properties:
- Stable sort: columns not in
schema_order(keyusize::MAX) preserve relative order after the ordered ones. - O(n log n) where n = number of columns (typically <100).
- Called once: at finish time, not per row.
Example¶
Given columns ["D", "A", "C", "B"] and schema_order = ["A", "B", "C"]:
["D", "A", "C", "B"]with rank keys[usize::MAX, 0, 2, 1]- "D" not in schema_order -> key =
usize::MAX - "A" at position 0 -> key = 0
- "C" at position 2 -> key = 2
- "B" at position 1 -> key = 1
- Sorted (stable):
["A", "B", "C", "D"]
Fast path vs merge path¶
The engine has two export paths. The choice depends on whether all batches have identical schemas.
Fast path (parallel export)¶
Conditions:
auto_dictis false, and all chunks agree on column variant keys (guaranteed byensure_schema+schema_order), ORauto_dictis true and schemas are consistent after a parallel per-chunkauto_dict_upgrade(dictionaries are unified across chunks first when needed)
Behavior:
- Each batch is exported independently in parallel
engines_to_record_batchesbuilds oneRecordBatchper chunk; it also runsunify_variants/promote_to_variantto reconcile storage variants across chunks before export- No sequential merge step needed
- Throughput: ~4,980 MB/s on 533 MB
Merge path (sequential export)¶
Conditions:
- Chunk schemas are inconsistent after upgrade (or inconsistent without
auto_dict): chunks disagree on a column's storage variant in a way the per-chunk export cannot reconcile ahead of time
Behavior:
- Batches are merged sequentially into one
TableBuilderviaextend merged.finish()produces a single batch- Throughput: ~4,497 MB/s on 533 MB (-10%)
Path selection¶
Simplified from ParallelExecutor::parse:
if !plan.auto_dict && schemas_consistent(&engines) {
return engines_to_record_batches(engines, &plan);
}
if plan.auto_dict {
engines.par_iter_mut().for_each(|e| e.auto_dict_upgrade());
if schemas_consistent(&engines) {
unify_dictionaries_across_chunks(&mut engines);
return engines_to_record_batches(engines, &plan);
}
}
// Merge path.
let mut merged = TableBuilder::with_plan(...);
for engine in engines {
merged.extend(engine)?;
}
Ok(vec![merged.finish()?])
schemas_consistent checks that all engines agree on column variant keys.
This is guaranteed when schema_order is set.
Column type resolution¶
ExecutionPlan::column_type resolves the storage type for a column:
pub fn column_type(&self, name: &str) -> FieldType {
// 1. Explicit type override
if let Some(ty) = self.field_types.get(name) {
return ty.clone();
}
// 2. Dictionary column
if self.dictionary_columns.contains(name) {
return FieldType::Dictionary;
}
// 3. Default: string
FieldType::String
}
Priority:
field_types[name](explicit override).dictionary_columnscontainsname(dictionary encoding).FieldType::String(default).
This is called during ensure_schema to create the correct column builder.
Unknown fields and strict types¶
The crate declares an UnknownFieldPolicy enum (Error / Skip) and
re-exports it, but no code path currently reads it; it is reserved for
future use. The actual behavior is fixed:
Error behavior (default)¶
With explicit schema (exact = true), unknown fields are recorded during
parsing and cause a hard error at finish():
unknown field "LateColumn" not in frozen schema (10 columns, exact=true);
pass schema=[...] with full column list or use full-scan discovery
The same error with exact=false fires when sampled discovery missed a
rare column (see Accuracy). This is the safe default: it
prevents silent data loss. With a partial schema (from_partial_plan,
exact = false), unknown fields are instead appended as new columns in
discovery order.
Unknown fields surface as Error::Merge. A related parse-time error mode
is strict_types: when set, a non-null string value that does not parse
as the declared field_types type is recorded and surfaced at finish()
as Error::Plan.
Integration with the pipeline¶
How schema flows through the engine¶
ExecutionPlan
|
+---> FrozenSchema::from_plan
| |
| +---> ParallelStreamOpts.schema
| | |
| | +---> DiscoverySink (if empty)
| | | |
| | | +---> sampled scan
| | | +---> FrozenSchema::from_discovered
| | |
| | +---> FrozenSchema::from_plan (if set)
| |
| +---> TableBuilder.ensure_schema (per chunk)
| | |
| | +---> ColumnBuilder::with_capacity (per missing column)
| | +---> freeze schema on the builder
| |
| +---> sort_columns (at finish)
| | |
| | +---> stable-sort column_order to match schema_order
| |
| +---> engines_to_record_batches (fast path)
| |
| +---> one RecordBatch per chunk (parallel)
|
+---> TableBuilder.push_field
|
+---> plan.resolve_field(raw_name) + field_index probe
| |
| +---> Some(slot) -> put into column slot
| +---> None -> skip (dropped)
|
+---> expect_slot memcmp fast path (stable layout, row 2+)
|
+---> observer hooks (if plan.observer is set):
| on_begin_row / on_put_field /
| on_row_accepted / on_row_rejected
|
+---> column_type(name) -> typed builder
How schema interacts with projection¶
schema_order itself is the primary projection mechanism: unlisted fields
are rejected by plan.resolve_field, so wants("unlisted_field") returns
false and the parser skips the field entirely (no scanning, no decoding).
The wanted-column set (schema columns plus filter-referenced fields) is
pre-declared in TableBuilder::with_plan, which makes the
wanted_mask/row_satisfied short-circuit stable from the first row, so
adapters can byte-jump to the row close as soon as every wanted column has
a value. At finish time the batch contains exactly the declared columns in
the declared order; filter-only columns are projected out, and declared
columns absent from the data are null-filled.
When DropFields is used, the plan's drop_fields set is populated.
During schema construction:
from_plan: the dropped field is still in the schema (caller declared it)from_discovered: the dropped field getsNonein the index
During parsing:
wants("dropped_field")returnsfalse(engine checksdrop_fields)- The parser skips the field entirely (no scanning, no decoding)
At export time:
- Dropped fields are not in the output (they were never built)
How schema interacts with rename¶
When RenameFields is used, the plan's field_map is populated. During
schema construction:
from_plan: the output name is used (rename already applied by caller)from_discovered:resolve_field(name)applies the rename, then maps to the output slot
During parsing:
put_field("raw_name", ...)is resolved viafield_mapto the output name- The value goes into the renamed column
How schema interacts with filter¶
When FilterRows is used, the plan's filter is populated. During parsing:
- Predicate-first buffered evaluation: field values are buffered per row;
the predicate is evaluated as soon as its fields are seen
(
eval_predicate), and at end of row the buffer is either committed to the columns (drain_buffered) or discarded without touching them. - Adaptive fallback: when the predicate column's ordinal is >= 4/5 of the
column count, buffering is disabled (
buffer_worthwhile = false) and the engine falls back to direct push with pop-on-reject infinish_row(pop()per column when the predicate fails). - Comparison flavor is per predicate:
Equal/NotEqualare string-based;Compare/CompareLiteralare typed using the columns'field_types, with numeric promotion (Int64 vs Float64 compares as float).
Performance budget¶
For a 533 MB file on a Ryzen 5800X:
| Phase | Time | Notes |
|---|---|---|
| Schema resolution | 0.005 ms | from_plan construction. |
ensure_schema |
0.1 ms | 16 chunks x 10 columns. |
sort_columns |
0.01 ms | Once at finish. |
resolve per field |
0.000015 ms | ~15 cycles (FxHash). |
| Total schema overhead | ~0.12 ms | <0.01% of parse time. |
Without explicit schema:
| Phase | Time | Notes |
|---|---|---|
| Discovery (parallel) | 5.3 ms | 16x2 MiB sampling at 533 MB; window count and size scale with file size. |
from_discovered |
0.01 ms | Apply renames/drops. |
ensure_schema |
0.1 ms | Same as above. |
sort_columns |
0.01 ms | Same as above. |
| Cache lookup | 0.001 ms | If cached. |
| Total schema overhead | ~5.4 ms | ~0.4% of parse time. |
Explicit schema saves ~5.3 ms per file. For 1,000 files, that is 5.3 seconds.
Thread safety¶
FrozenSchema is Clone + Send + Sync. It is built once and shared via
Arc<FrozenSchema> across all workers. No locks are needed after construction.
The schema cache uses RwLock<FxHashMap> for concurrent access:
- Read lock: schema lookup (common, concurrent)
- Write lock: schema insert (rare, serialized)
Cache statistics use AtomicU64 for lock-free increment.