Skip to main content

linkrs_query/executor/streaming/chunk/
core.rs

1//! DataChunk core: struct definition, construction, and basic access
2
3use super::schema::{ColumnInfo, Schema};
4use super::typed::TypedColumn;
5use crate::executor::base::MemoryReservation;
6use crate::executor::streaming::runtime::ColumnarStats;
7use crate::executor::streaming::slot::{SlotId, SlotLayout};
8use linkrs_core::columnar::MaterializedBatch;
9use linkrs_core::Value;
10use std::sync::Arc;
11
12/// A chunk of rows processed in streaming execution
13#[derive(Debug)]
14pub struct DataChunk {
15    /// Row data with Value types
16    pub rows: Vec<Vec<Value>>,
17    /// Optional typed column layout.
18    pub typed_columns: Option<Vec<TypedColumn>>,
19    /// When true, `build_typed_columns` was intentionally skipped at
20    /// construction (e.g. join output). The typed layout is rebuilt on
21    /// first consumer access when the columnar policy allows it.
22    pub(crate) columnar_build_deferred: bool,
23    /// Selection vector.
24    pub selection: Option<Vec<usize>>,
25    /// Symbolic row multiplicity (Ladybug `ResultSet::multiplicity` analogue).
26    ///
27    /// Transparent operators may bump this instead of physically duplicating
28    /// rows. `1` means every visible row appears once. The expanded
29    /// (flat) row count is `visible_count * multiplicity`.
30    pub multiplicity: u64,
31    /// Schema information (column names and types)
32    pub schema: Arc<Schema>,
33    /// Slot layout for slot-based value access.
34    pub layout: Arc<SlotLayout>,
35    /// Memory reservation for this chunk's data.
36    pub memory_reservation: Option<MemoryReservation>,
37    /// Query-level columnar fast-path counters (observability).
38    pub columnar_stats: Option<Arc<ColumnarStats>>,
39}
40
41impl Clone for DataChunk {
42    fn clone(&self) -> Self {
43        Self {
44            rows: self.rows.clone(),
45            typed_columns: self.typed_columns.clone(),
46            // A clone resolves the layout eagerly or not at all: it either
47            // carries the built typed columns above or rebuilds on demand
48            // only when explicitly marked deferred again by its producer.
49            columnar_build_deferred: false,
50            selection: self.selection.clone(),
51            multiplicity: self.multiplicity,
52            schema: self.schema.clone(),
53            layout: Arc::clone(&self.layout),
54            memory_reservation: None,
55            columnar_stats: self.columnar_stats.clone(),
56        }
57    }
58}
59
60impl DataChunk {
61    // ── Construction ──
62
63    pub fn new(rows: Vec<Vec<Value>>, schema: Arc<Schema>) -> Self {
64        let layout = Arc::new(SlotLayout::from_names(
65            &schema
66                .columns
67                .iter()
68                .map(|c| c.name.clone())
69                .collect::<Vec<_>>(),
70        ));
71        Self {
72            rows,
73            typed_columns: None,
74            columnar_build_deferred: false,
75            selection: None,
76            multiplicity: 1,
77            schema,
78            layout,
79            memory_reservation: None,
80            columnar_stats: None,
81        }
82    }
83
84    pub fn with_memory_reservation(mut self, reservation: MemoryReservation) -> Self {
85        self.memory_reservation = Some(reservation);
86        self
87    }
88
89    pub fn with_columnar_stats(mut self, stats: Arc<ColumnarStats>) -> Self {
90        self.columnar_stats = Some(stats);
91        self
92    }
93
94    pub fn take_memory_reservation(&mut self) -> Option<MemoryReservation> {
95        self.memory_reservation.take()
96    }
97
98    pub fn new_with_layout(rows: Vec<Vec<Value>>, layout: Arc<SlotLayout>) -> Self {
99        Self::try_new_with_layout(rows, layout).expect("DataChunk row width mismatch")
100    }
101
102    pub fn try_new_with_layout(
103        rows: Vec<Vec<Value>>,
104        layout: Arc<SlotLayout>,
105    ) -> Result<Self, linkrs_core::error::QueryError> {
106        let row_width = rows.first().map(Vec::len).unwrap_or(0);
107        if !layout.is_empty()
108            && !rows.is_empty()
109            && !rows.iter().all(|row| row.len() == layout.len())
110        {
111            return Err(linkrs_core::error::QueryError::execution(format!(
112                "DataChunk::new_with_layout: row width {} does not match layout width {}",
113                row_width,
114                layout.len()
115            )));
116        }
117        let columns: Vec<ColumnInfo> = layout
118            .slots
119            .iter()
120            .map(|info| ColumnInfo {
121                name: info.name.clone(),
122                data_type: info
123                    .data_type
124                    .as_ref()
125                    .map(|dt| dt.to_string().to_lowercase())
126                    .unwrap_or_else(|| "unknown".to_string()),
127            })
128            .collect();
129        let schema = Arc::new(Schema::new(columns));
130        Ok(Self {
131            rows,
132            typed_columns: None,
133            columnar_build_deferred: false,
134            selection: None,
135            multiplicity: 1,
136            schema,
137            layout,
138            memory_reservation: None,
139            columnar_stats: None,
140        })
141    }
142
143    pub fn from_rows(rows: Vec<Vec<Value>>) -> Self {
144        Self::from_rows_with_col_names(rows, None)
145    }
146
147    pub fn from_rows_with_col_names(rows: Vec<Vec<Value>>, col_names: Option<Vec<String>>) -> Self {
148        let schema = if rows.is_empty() {
149            if let Some(names) = col_names {
150                Arc::new(Schema::new(
151                    names
152                        .into_iter()
153                        .map(|name| ColumnInfo {
154                            name,
155                            data_type: "unknown".to_string(),
156                        })
157                        .collect(),
158                ))
159            } else {
160                Arc::new(Schema::empty())
161            }
162        } else {
163            let col_count = rows[0].len();
164            let columns = (0..col_count)
165                .map(|i| {
166                    let name = col_names
167                        .as_ref()
168                        .and_then(|names| names.get(i).cloned())
169                        .unwrap_or_else(|| format!("col_{}", i));
170
171                    let data_type = if let Some(row) = rows.first() {
172                        if let Some(val) = row.get(i) {
173                            match val {
174                                Value::BigInt(_) => "bigint",
175                                Value::Int(_) => "int",
176                                Value::Double(_) => "double",
177                                Value::Float(_) => "float",
178                                Value::String(_) => "string",
179                                Value::Bool(_) => "bool",
180                                Value::Null(_) => "null",
181                                _ => "unknown",
182                            }
183                        } else {
184                            "unknown"
185                        }
186                    } else {
187                        "unknown"
188                    };
189
190                    ColumnInfo {
191                        name,
192                        data_type: data_type.to_string(),
193                    }
194                })
195                .collect();
196            Arc::new(Schema::new(columns))
197        };
198        let layout = Arc::new(SlotLayout::from_names(
199            &schema
200                .columns
201                .iter()
202                .map(|c| c.name.clone())
203                .collect::<Vec<_>>(),
204        ));
205        Self {
206            rows,
207            typed_columns: None,
208            columnar_build_deferred: false,
209            selection: None,
210            multiplicity: 1,
211            schema,
212            layout,
213            memory_reservation: None,
214            columnar_stats: None,
215        }
216    }
217
218    pub(crate) fn from_columns(columns: Vec<Vec<Value>>, layout: Arc<SlotLayout>) -> Self {
219        // Compatibility transpose: row storage is authoritative.
220        // Production code must use `project_columns`; this stays
221        // crate-private for row-major test fixtures only.
222        let num_cols = columns.len();
223        assert!(
224            layout.is_empty() || num_cols == layout.len(),
225            "DataChunk::from_columns: column count {} does not match layout width {}",
226            num_cols,
227            layout.len()
228        );
229        let num_rows = columns.first().map(|c| c.len()).unwrap_or(0);
230        assert!(
231            columns.iter().all(|c| c.len() == num_rows),
232            "DataChunk::from_columns: column length mismatch"
233        );
234
235        let mut rows = vec![Vec::with_capacity(num_cols); num_rows];
236        for col in columns.iter().take(num_cols) {
237            for (row_idx, val) in col.iter().enumerate().take(num_rows) {
238                rows[row_idx].push(val.clone());
239            }
240        }
241
242        let schema = Arc::new(Schema::new(
243            layout
244                .slots
245                .iter()
246                .map(|info| ColumnInfo {
247                    name: info.name.clone(),
248                    data_type: info
249                        .data_type
250                        .as_ref()
251                        .map(|dt| dt.to_string().to_lowercase())
252                        .unwrap_or_else(|| "unknown".to_string()),
253                })
254                .collect(),
255        ));
256
257        Self {
258            rows,
259            typed_columns: None,
260            columnar_build_deferred: false,
261            selection: None,
262            multiplicity: 1,
263            schema,
264            layout,
265            memory_reservation: None,
266            columnar_stats: None,
267        }
268    }
269
270    /// Columnar projection assembly: build an output chunk from evaluated
271    /// output columns without a row intermediate.
272    ///
273    /// This is the designated constructor for the streaming `Project` fast
274    /// path (passthrough / constant columns gathered or broadcast upstream).
275    /// Row storage stays authoritative downstream; the transpose into rows
276    /// is the single compatibility edge until the chunk itself is columnar.
277    /// The typed rebuild here is unconditional by design: projected outputs
278    /// are bounded by the input chunk size, unlike join outputs which honor
279    /// the shared columnar gate.
280    pub fn project_columns(columns: Vec<Vec<Value>>, layout: Arc<SlotLayout>) -> Self {
281        let mut chunk = Self::from_columns(columns, layout);
282        // Symmetric with the `Assign` rebuild: the projected chunk starts
283        // row-major, so re-derive the typed layout here; otherwise every
284        // non-trivial projection drops the columnar fast path downstream.
285        chunk.build_typed_columns(true);
286        chunk
287    }
288
289    /// Build a row chunk from a columnar batch slice.
290    ///
291    /// Coexistence API: the batch stays the operator state authority while
292    /// the chunk carries the row view downstream. A transpose is still
293    /// performed internally until the chunk itself becomes columnar.
294    pub fn from_batch(batch: &MaterializedBatch, layout: Arc<SlotLayout>) -> Self {
295        let rows = batch.to_rows();
296        Self::try_new_with_layout(rows, layout).expect("DataChunk row width mismatch")
297    }
298
299    /// Build a row chunk from a row range of a columnar batch.
300    pub fn slice_from_batch(
301        batch: &MaterializedBatch,
302        offset: usize,
303        len: usize,
304        layout: Arc<SlotLayout>,
305    ) -> Self {
306        Self::from_batch(&batch.slice_rows(offset, len), layout)
307    }
308
309    /// Convert this chunk back into a columnar batch (single transpose edge).
310    pub fn to_batch(&self) -> MaterializedBatch {
311        let mut batch = MaterializedBatch::new(self.num_columns(), 0);
312        let names: Arc<[String]> = Arc::from(
313            self.schema
314                .columns
315                .iter()
316                .map(|c| c.name.clone())
317                .collect::<Vec<_>>(),
318        );
319        batch.set_schema_names(names);
320        for row in &self.rows {
321            batch.append_row(row.clone());
322        }
323        batch
324    }
325
326    /// Memory estimate for the *visible expanded* rows
327    /// (`visible_count * multiplicity`), used for pool/tracker accounting.
328    pub fn estimated_bytes(&self) -> usize {
329        let base = crate::executor::base::MemoryBudget::estimate_rows_memory(&self.rows);
330        if self.rows.is_empty() || base == 0 {
331            return 0;
332        }
333        let visible = self.visible_count();
334        if visible == self.rows.len() && self.multiplicity <= 1 {
335            return base;
336        }
337        let per_row = base.saturating_div(self.rows.len().max(1));
338        (visible as u64)
339            .saturating_mul(self.multiplicity)
340            .saturating_mul(per_row as u64)
341            .min(usize::MAX as u64) as usize
342    }
343
344    // ── Basic access ──
345
346    pub fn len(&self) -> usize {
347        self.rows.len()
348    }
349
350    pub fn is_empty(&self) -> bool {
351        self.rows.is_empty()
352    }
353
354    pub fn num_columns(&self) -> usize {
355        self.schema.column_count()
356    }
357
358    pub fn col_names(&self) -> Vec<String> {
359        self.schema.columns.iter().map(|c| c.name.clone()).collect()
360    }
361
362    pub fn col_name(&self, index: usize) -> Option<String> {
363        self.schema.columns.get(index).map(|c| c.name.clone())
364    }
365
366    pub fn col_name_index(&self) -> std::collections::HashMap<String, usize> {
367        self.schema
368            .columns
369            .iter()
370            .enumerate()
371            .map(|(i, col)| (col.name.clone(), i))
372            .collect()
373    }
374
375    pub fn get_layout(&self) -> Arc<SlotLayout> {
376        Arc::clone(&self.layout)
377    }
378
379    pub fn get_by_slot(&self, row_idx: usize, slot: SlotId) -> Option<Value> {
380        self.rows
381            .get(row_idx)
382            .and_then(|row| row.get(slot).cloned())
383    }
384
385    /// Column view over `Value`s, derived on demand from the typed layout.
386    ///
387    /// New expression code should prefer `typed_column`; this stays for join
388    /// keys and fallback paths that require owned `Value` columns. The
389    /// column is rebuilt from the typed layout (or cloned from rows) on
390    /// every call and never cached: row storage is authoritative.
391    pub fn get_column(&mut self, slot: SlotId) -> Option<Vec<Value>> {
392        if slot >= self.layout.len() {
393            return None;
394        }
395        // Prefer the typed layout, resolving a deferred join-output build
396        // first; fall back to a row-major clone.
397        self.ensure_typed_columns();
398        if let Some(ref typed) = self.typed_columns {
399            if let Some(col) = typed.get(slot) {
400                return Some(col.to_values());
401            }
402        }
403        Some(self.rows.iter().map(|row| row[slot].clone()).collect())
404    }
405
406    pub fn column_ref(&self, slot: SlotId) -> Option<Vec<&Value>> {
407        if slot >= self.layout.len() {
408            return None;
409        }
410        Some(self.rows.iter().map(|row| &row[slot]).collect())
411    }
412
413    pub fn get_typed_by_slot(&self, row_idx: usize, slot: SlotId) -> Option<Value> {
414        if let Some(ref typed) = self.typed_columns {
415            if let Some(col) = typed.get(slot) {
416                return col.value_at(row_idx);
417            }
418        }
419        self.get_by_slot(row_idx, slot)
420    }
421
422    pub fn row_at(&self, i: usize) -> &[Value] {
423        &self.rows[i]
424    }
425
426    /// Take the physical rows out for buffer reuse, resetting selection,
427    /// multiplicity, and derived typed state.
428    ///
429    /// Unlike [`Clone`](Self::clone) (documented deep copy), this moves the
430    /// allocation out so the caller can return the drained buffers to
431    /// [`super::pool::RowBufferPool`] via `release_rows` instead of dropping
432    /// them. Prefer move-first construction plus this pool over cloning.
433    pub fn take_rows_for_reuse(&mut self) -> Vec<Vec<Value>> {
434        self.selection = None;
435        self.columnar_build_deferred = false;
436        if self.typed_columns.take().is_some() {
437            // The typed layout is dropped without a downstream consumer; count
438            // it so a high build cost with no reader becomes visible.
439            if let Some(stats) = &self.columnar_stats {
440                stats.record_wasted_build();
441            }
442        }
443        self.multiplicity = 1;
444        std::mem::take(&mut self.rows)
445    }
446
447    // ── Typed column layout ──
448
449    /// Build the typed column layout for this chunk.
450    ///
451    /// `use_columnar` carries the adaptive [`ColumnarPolicy`] decision from
452    /// the producing operator: when the learned hit rate falls below the
453    /// threshold the chunk stays row-based. The typed layout is otherwise
454    /// always attempted (there is no global off switch).
455    /// Returns the number of extra typed bytes allocated.
456    pub fn build_typed_columns(&mut self, use_columnar: bool) -> usize {
457        if !use_columnar {
458            return 0;
459        }
460        // An explicit build resolves any pending deferred build, whether
461        // the layout already exists or is rebuilt below.
462        self.columnar_build_deferred = false;
463        if self.typed_columns.is_some() {
464            return 0;
465        }
466        // Global memory pressure: skip building new acceleration caches and
467        // stay on the plain row path (see `memory_watermark` degradation
468        // contract).
469        if !linkrs_storage::memory_watermark::pressure().allows_columnar() {
470            return 0;
471        }
472        let num_cols = self.num_columns();
473        if self.rows.is_empty() || num_cols == 0 {
474            return 0;
475        }
476        let num_rows = self.rows.len();
477        let mut typed = Vec::with_capacity(num_cols);
478        let mut extra_bytes = 0usize;
479        for col_idx in 0..num_cols {
480            let first = &self.rows[0][col_idx];
481            let representative = if matches!(first, Value::Null(_)) {
482                // A leading NULL cannot reveal the column kind; probe the
483                // first non-NULL value so NULL-leading homogeneous columns
484                // stay on the typed path (all-NULL columns fall back).
485                super::kind::first_non_null(self.rows.iter().map(|row| &row[col_idx]))
486                    .unwrap_or(first)
487            } else {
488                first
489            };
490            let Some(kind) = super::kind::value_to_kind(representative) else {
491                // A non-scalar value cannot reveal the column kind: fall back
492                // so the per-row path keeps the exact `Value` semantics.
493                typed.push(TypedColumn::Fallback(
494                    self.rows.iter().map(|row| row[col_idx].clone()).collect(),
495                ));
496                continue;
497            };
498            let mut ok = true;
499            let mut has_null = false;
500            let mut bitmap = vec![0u64; num_rows.div_ceil(64)];
501            let mut builder = super::kind::TypedColumnBuilder::with_capacity(kind, num_rows);
502            for (i, row) in self.rows.iter().enumerate() {
503                match builder.push_value(&row[col_idx]) {
504                    super::kind::PushOutcome::Value => bitmap[i / 64] |= 1u64 << (i % 64),
505                    super::kind::PushOutcome::Null => has_null = true,
506                    super::kind::PushOutcome::Mismatch => {
507                        ok = false;
508                        break;
509                    }
510                }
511            }
512            if ok {
513                extra_bytes += builder.estimated_bytes();
514                extra_bytes += bitmap.capacity() * std::mem::size_of::<u64>();
515                typed.push(builder.finish(has_null, bitmap));
516            } else {
517                typed.push(TypedColumn::Fallback(
518                    self.rows.iter().map(|row| row[col_idx].clone()).collect(),
519                ));
520            }
521        }
522        self.typed_columns = Some(typed);
523        extra_bytes
524    }
525
526    pub fn typed_column(&self, slot: SlotId) -> Option<&TypedColumn> {
527        self.typed_columns.as_ref().and_then(|cols| cols.get(slot))
528    }
529
530    /// Row count served by the typed layout when the row view was skipped.
531    ///
532    /// Rowless chunks (storage scans whose planner annotation proved the
533    /// direct consumer never reads rows) carry data only in `typed_columns`;
534    /// every typed arm implements `len`, and assembly keeps the arms in
535    /// lockstep exactly like rows. Returns `None` when there is no typed
536    /// layout, so row-based consumers keep their existing empty semantics.
537    pub fn typed_len(&self) -> Option<usize> {
538        let cols = self.typed_columns.as_ref()?;
539        let mut len: Option<usize> = None;
540        for col in cols {
541            let arm = col.len();
542            debug_assert!(
543                len.is_none_or(|n| n == arm),
544                "typed arms must stay in lockstep like rows"
545            );
546            len = Some(len.unwrap_or(arm));
547        }
548        len
549    }
550
551    /// Rebuild the typed layout when construction deferred it.
552    ///
553    /// Join outputs skip the eager build and set the deferred flag; the
554    /// first typed consumer calls this to pay the build cost lazily. The
555    /// columnar policy gate already ran at construction, so the rebuild
556    /// is unconditional. Returns the extra typed bytes allocated.
557    pub fn ensure_typed_columns(&mut self) -> usize {
558        if self.typed_columns.is_none() && self.columnar_build_deferred {
559            return self.build_typed_columns(true);
560        }
561        0
562    }
563
564    // ── Columnar stats helpers ──
565
566    pub(super) fn count_columnar(&self, hit: bool) {
567        if let Some(stats) = &self.columnar_stats {
568            if hit {
569                stats.record_hit();
570            } else {
571                stats.record_miss();
572            }
573        }
574    }
575
576    pub(crate) fn count_typed_hit(&self) {
577        if let Some(stats) = &self.columnar_stats {
578            stats.record_typed_hit();
579        }
580    }
581
582    /// record an evaluation served by the selection-aware visible-row
583    /// fast path (selection consumed in place, no materialization).
584    pub(super) fn count_selection_pushed(&self) {
585        if let Some(stats) = &self.columnar_stats {
586            stats.record_selection_pushed();
587        }
588    }
589}