Skip to main content

exoware_sql/
schema.rs

1use std::any::Any;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use bytes::Bytes;
6use datafusion::arrow::datatypes::{DataType, SchemaRef};
7use datafusion::catalog::Session;
8use datafusion::common::{DataFusionError, Result as DataFusionResult, SchemaExt};
9use datafusion::datasource::sink::DataSinkExec;
10use datafusion::datasource::TableProvider;
11use datafusion::execution::SessionStateBuilder;
12use datafusion::logical_expr::dml::InsertOp;
13use datafusion::logical_expr::{Expr, TableProviderFilterPushDown, TableType};
14use datafusion::physical_plan::ExecutionPlan;
15use datafusion::prelude::SessionContext;
16use exoware_sdk::kv_codec::decode_stored_row;
17use exoware_sdk::PrefixedStoreClient;
18
19use crate::aggregate::KvAggregatePushdownRule;
20use crate::codec::*;
21use crate::predicate::*;
22use crate::scan::*;
23use crate::types::*;
24use crate::writer::*;
25
26pub(crate) fn register_kv_table(
27    ctx: &SessionContext,
28    table_name: &str,
29    client: PrefixedStoreClient,
30    config: KvTableConfig,
31) -> DataFusionResult<()> {
32    let table = Arc::new(
33        KvTable::new(client, config)
34            .map_err(|e| DataFusionError::Execution(format!("invalid table config: {e}")))?,
35    );
36    let _ = ctx.register_table(table_name, table)?;
37    Ok(())
38}
39
40pub struct KvSchema {
41    client: PrefixedStoreClient,
42    tables: Vec<(String, KvTableConfig)>,
43    next_prefix: u8,
44}
45
46impl KvSchema {
47    /// Build a schema over `client`'s namespace prefix. When the Store is
48    /// co-tenanted, the caller must pick a prefix disjoint from every co-tenant
49    /// so SQL's prefix-bounded scans can't observe another's keys.
50    pub fn new(client: PrefixedStoreClient) -> Self {
51        Self {
52            client,
53            tables: Vec::new(),
54            next_prefix: 0,
55        }
56    }
57
58    pub fn table(
59        mut self,
60        name: impl Into<String>,
61        columns: Vec<TableColumnConfig>,
62        primary_key_columns: Vec<String>,
63        index_specs: Vec<IndexSpec>,
64    ) -> Result<Self, String> {
65        if self.tables.len() >= MAX_TABLES {
66            return Err(format!("too many tables for key layout (max {MAX_TABLES})"));
67        }
68        let prefix = self.next_prefix;
69        let config = KvTableConfig::new(prefix, columns, primary_key_columns, index_specs)?;
70        self.tables.push((name.into(), config));
71        self.next_prefix = self.next_prefix.wrapping_add(1);
72        Ok(self)
73    }
74
75    pub fn orders_table(
76        self,
77        table_name: impl Into<String>,
78        index_specs: Vec<IndexSpec>,
79    ) -> Result<Self, String> {
80        self.table(
81            table_name,
82            vec![
83                TableColumnConfig::new("region", DataType::Utf8, false),
84                TableColumnConfig::new("customer_id", DataType::Int64, false),
85                TableColumnConfig::new("order_id", DataType::Int64, false),
86                TableColumnConfig::new("amount_cents", DataType::Int64, false),
87                TableColumnConfig::new("status", DataType::Utf8, false),
88            ],
89            vec!["order_id".to_string()],
90            index_specs,
91        )
92    }
93
94    /// Create a table with a versioned composite primary key.
95    ///
96    /// The entity column and version column (UInt64) together form the
97    /// composite primary key. The entity can be any supported primary-key
98    /// type, including variable-length logical keys encoded through the
99    /// crate's ordered variable-length `Utf8` mapping.
100    ///
101    /// Versions sort
102    /// numerically via big-endian encoding, so a reverse range scan
103    /// from `(entity, V)` downward with LIMIT 1 yields the latest
104    /// version <= V. See `examples/versioned_kv.rs` for the basic
105    /// query pattern plus an immutable-friendly companion watermark
106    /// table pattern for out-of-order batch uploads.
107    pub fn table_versioned(
108        self,
109        name: impl Into<String>,
110        columns: Vec<TableColumnConfig>,
111        entity_column: impl Into<String>,
112        version_column: impl Into<String>,
113        index_specs: Vec<IndexSpec>,
114    ) -> Result<Self, String> {
115        let entity = entity_column.into();
116        let version = version_column.into();
117        self.table(name, columns, vec![entity, version], index_specs)
118    }
119
120    pub fn table_count(&self) -> usize {
121        self.tables.len()
122    }
123
124    pub(crate) fn client(&self) -> &PrefixedStoreClient {
125        &self.client
126    }
127
128    pub(crate) fn tables(&self) -> &[(String, KvTableConfig)] {
129        &self.tables
130    }
131
132    pub fn register_all(self, ctx: &SessionContext) -> DataFusionResult<()> {
133        register_kv_optimizers(ctx);
134        for (name, config) in &self.tables {
135            register_kv_table(ctx, name, self.client.clone(), config.clone())?;
136        }
137        Ok(())
138    }
139
140    pub fn batch_writer(&self) -> BatchWriter {
141        BatchWriter::new(self.client.clone(), &self.tables)
142    }
143
144    /// Backfill secondary index entries after adding new index specs.
145    ///
146    /// `previous_index_specs` must represent the index list used when existing
147    /// rows were written. The current schema's index list must be an append-only
148    /// extension of that list (same order/layout for existing indexes, with new
149    /// indexes only added at the tail).
150    ///
151    /// Operational ordering requirement: start writing new rows with the new
152    /// index specs before backfilling historical rows, or rows written during
153    /// the backfill window may be missing from the new index.
154    pub async fn backfill_added_indexes(
155        &self,
156        table_name: &str,
157        previous_index_specs: &[IndexSpec],
158    ) -> DataFusionResult<IndexBackfillReport> {
159        self.backfill_added_indexes_with_options(
160            table_name,
161            previous_index_specs,
162            IndexBackfillOptions::default(),
163        )
164        .await
165    }
166
167    /// Backfill secondary index entries after adding new index specs, with
168    /// configurable row page size for the full-scan read.
169    pub async fn backfill_added_indexes_with_options(
170        &self,
171        table_name: &str,
172        previous_index_specs: &[IndexSpec],
173        options: IndexBackfillOptions,
174    ) -> DataFusionResult<IndexBackfillReport> {
175        self.backfill_added_indexes_with_options_and_progress(
176            table_name,
177            previous_index_specs,
178            options,
179            None,
180        )
181        .await
182    }
183
184    /// Backfill secondary index entries after adding new index specs, with
185    /// configurable row page size for the full-scan read and an optional
186    /// progress event channel.
187    ///
188    /// Progress events are emitted only after buffered ingest writes for the
189    /// reported cursor are flushed, so `Progress.next_cursor` can be persisted
190    /// and used to resume later.
191    pub async fn backfill_added_indexes_with_options_and_progress(
192        &self,
193        table_name: &str,
194        previous_index_specs: &[IndexSpec],
195        options: IndexBackfillOptions,
196        progress_tx: Option<&tokio::sync::mpsc::UnboundedSender<IndexBackfillEvent>>,
197    ) -> DataFusionResult<IndexBackfillReport> {
198        if options.row_batch_size == 0 {
199            return Err(DataFusionError::Execution(
200                "index backfill row_batch_size must be > 0".to_string(),
201            ));
202        }
203
204        let config = self
205            .tables
206            .iter()
207            .find(|(name, _)| name == table_name)
208            .map(|(_, config)| config.clone())
209            .ok_or_else(|| {
210                DataFusionError::Execution(format!(
211                    "unknown table '{table_name}' for index backfill"
212                ))
213            })?;
214
215        let model = TableModel::from_config(&config)
216            .map_err(|e| DataFusionError::Execution(format!("invalid table config: {e}")))?;
217        let current_specs = model
218            .resolve_index_specs(&config.index_specs)
219            .map_err(|e| DataFusionError::Execution(format!("invalid index specs: {e}")))?;
220        let previous_specs = model
221            .resolve_index_specs(previous_index_specs)
222            .map_err(|e| {
223                DataFusionError::Execution(format!("invalid previous index specs: {e}"))
224            })?;
225
226        if previous_specs.len() > current_specs.len() {
227            return Err(DataFusionError::Execution(format!(
228                "table '{table_name}' previous index count ({}) exceeds current index count ({})",
229                previous_specs.len(),
230                current_specs.len()
231            )));
232        }
233        for (idx, previous) in previous_specs.iter().enumerate() {
234            let current = &current_specs[idx];
235            if !resolved_index_layout_matches(previous, current) {
236                return Err(DataFusionError::Execution(format!(
237                    "table '{table_name}' index evolution must be append-only; index at position {} changed",
238                    idx + 1
239                )));
240            }
241        }
242
243        let full_range = primary_key_prefix_range(model.table_prefix);
244        let mut cursor = options
245            .start_from_primary_key
246            .unwrap_or_else(|| full_range.start.clone());
247        if !model.primary_key_prefix.matches(&cursor) {
248            return Err(DataFusionError::Execution(
249                "index backfill start_from_primary_key must use this table's primary-key prefix"
250                    .to_string(),
251            ));
252        }
253        if cursor < full_range.start || cursor > full_range.end {
254            return Err(DataFusionError::Execution(
255                "index backfill start_from_primary_key is outside table key range".to_string(),
256            ));
257        }
258
259        let new_specs = current_specs[previous_specs.len()..].to_vec();
260        if new_specs.is_empty() {
261            let report = IndexBackfillReport::default();
262            send_backfill_event(
263                progress_tx,
264                IndexBackfillEvent::Started {
265                    table_name: table_name.to_string(),
266                    indexes_backfilled: 0,
267                    row_batch_size: options.row_batch_size,
268                    start_cursor: cursor.clone(),
269                },
270            );
271            send_backfill_event(progress_tx, IndexBackfillEvent::Completed { report });
272            return Ok(report);
273        }
274
275        let mut report = IndexBackfillReport {
276            scanned_rows: 0,
277            indexes_backfilled: new_specs.len(),
278            index_entries_written: 0,
279        };
280        let mut pending_keys = Vec::new();
281        let mut pending_values: Vec<Bytes> = Vec::new();
282        let session = self.client.create_session();
283        let decode_pk_mask = vec![true; model.primary_key_kinds.len()];
284        send_backfill_event(
285            progress_tx,
286            IndexBackfillEvent::Started {
287                table_name: table_name.to_string(),
288                indexes_backfilled: new_specs.len(),
289                row_batch_size: options.row_batch_size,
290                start_cursor: cursor.clone(),
291            },
292        );
293
294        loop {
295            let mut stream = session
296                .range_stream(
297                    &cursor,
298                    &full_range.end,
299                    options.row_batch_size,
300                    options.row_batch_size,
301                )
302                .await
303                .map_err(|e| DataFusionError::External(Box::new(e)))?;
304            let mut last_key = None;
305            while let Some(chunk) = stream
306                .next_chunk()
307                .await
308                .map_err(|e| DataFusionError::External(Box::new(e)))?
309            {
310                for (base_key, base_value) in &chunk.rows {
311                    last_key = Some(base_key.clone());
312                    let Some(pk_values) = decode_primary_key_selected(
313                        model.table_prefix,
314                        base_key,
315                        &model,
316                        &decode_pk_mask,
317                    ) else {
318                        return Err(DataFusionError::Execution(format!(
319                            "invalid primary key while backfilling index (key={})",
320                            hex::encode(base_key)
321                        )));
322                    };
323                    let archived = decode_stored_row(base_value).map_err(|e| {
324                        DataFusionError::Execution(format!(
325                            "invalid base row payload while backfilling index (key={}): {e}",
326                            hex::encode(base_key)
327                        ))
328                    })?;
329                    if archived.values.len() != model.columns.len() {
330                        return Err(DataFusionError::Execution(format!(
331                            "invalid base row payload while backfilling index (key={})",
332                            hex::encode(base_key)
333                        )));
334                    }
335                    report.scanned_rows += 1;
336
337                    for spec in &new_specs {
338                        let index_key = encode_secondary_index_key_from_parts(
339                            model.table_prefix,
340                            spec,
341                            &model,
342                            &pk_values,
343                            &archived,
344                        )?;
345                        let index_value =
346                            encode_secondary_index_value_from_archived(&archived, &model, spec)?;
347                        pending_keys.push(index_key);
348                        pending_values.push(index_value.into());
349                        report.index_entries_written += 1;
350                    }
351
352                    if pending_keys.len() >= INDEX_BACKFILL_FLUSH_ENTRIES {
353                        flush_ingest_batch(&self.client, &mut pending_keys, &mut pending_values)
354                            .await?;
355                    }
356                }
357            }
358            let Some(last_key) = last_key else {
359                break;
360            };
361
362            let next_cursor = if last_key >= full_range.end {
363                None
364            } else {
365                exoware_sdk::keys::next_key(&last_key)
366            };
367            if !pending_keys.is_empty() {
368                flush_ingest_batch(&self.client, &mut pending_keys, &mut pending_values).await?;
369            }
370            send_backfill_event(
371                progress_tx,
372                IndexBackfillEvent::Progress {
373                    scanned_rows: report.scanned_rows,
374                    index_entries_written: report.index_entries_written,
375                    last_scanned_primary_key: last_key,
376                    next_cursor: next_cursor.clone(),
377                },
378            );
379
380            if let Some(next) = next_cursor {
381                cursor = next;
382            } else {
383                break;
384            }
385        }
386
387        if !pending_keys.is_empty() {
388            flush_ingest_batch(&self.client, &mut pending_keys, &mut pending_values).await?;
389        }
390        send_backfill_event(progress_tx, IndexBackfillEvent::Completed { report });
391        Ok(report)
392    }
393}
394
395fn register_kv_optimizers(ctx: &SessionContext) {
396    let _ = ctx.remove_optimizer_rule("kv_aggregate_pushdown");
397    ctx.add_optimizer_rule(Arc::new(KvAggregatePushdownRule::new()));
398
399    let state_ref = ctx.state_ref();
400    let mut state = state_ref.write();
401    let mut rules = state
402        .physical_optimizers()
403        .iter()
404        .filter(|rule| rule.name() != "kv_topk_sort_pushdown")
405        .cloned()
406        .collect::<Vec<_>>();
407    let insert_at = rules
408        .iter()
409        .position(|rule| rule.name() == "SanityCheckPlan")
410        .unwrap_or(rules.len());
411    rules.insert(insert_at, Arc::new(KvTopKSortPushdownRule::new()));
412    let new_state = SessionStateBuilder::new_from_existing(state.clone())
413        .with_physical_optimizer_rules(rules)
414        .build();
415    *state = new_state;
416}
417
418pub(crate) fn send_backfill_event(
419    progress_tx: Option<&tokio::sync::mpsc::UnboundedSender<IndexBackfillEvent>>,
420    event: IndexBackfillEvent,
421) {
422    if let Some(tx) = progress_tx {
423        let _ = tx.send(event);
424    }
425}
426
427pub(crate) fn resolved_index_layout_matches(
428    previous: &ResolvedIndexSpec,
429    current: &ResolvedIndexSpec,
430) -> bool {
431    previous.id == current.id
432        && previous.name == current.name
433        && previous.layout == current.layout
434        && previous.key_columns == current.key_columns
435        && previous.value_column_mask == current.value_column_mask
436        && previous.key_columns_width == current.key_columns_width
437}
438
439#[async_trait]
440impl TableProvider for KvTable {
441    fn as_any(&self) -> &dyn Any {
442        self
443    }
444
445    fn schema(&self) -> SchemaRef {
446        self.model.schema.clone()
447    }
448
449    fn table_type(&self) -> TableType {
450        TableType::Base
451    }
452
453    fn supports_filters_pushdown(
454        &self,
455        filters: &[&Expr],
456    ) -> DataFusionResult<Vec<TableProviderFilterPushDown>> {
457        Ok(filters
458            .iter()
459            .map(|expr| {
460                if QueryPredicate::supports_filter(expr, &self.model) {
461                    TableProviderFilterPushDown::Exact
462                } else {
463                    TableProviderFilterPushDown::Unsupported
464                }
465            })
466            .collect())
467    }
468
469    async fn scan(
470        &self,
471        _state: &dyn Session,
472        projection: Option<&Vec<usize>>,
473        filters: &[Expr],
474        limit: Option<usize>,
475    ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
476        let predicate = QueryPredicate::from_filters(filters, &self.model);
477        let projected_schema = match projection {
478            Some(proj) => Arc::new(self.model.schema.project(proj)?),
479            None => self.model.schema.clone(),
480        };
481        Ok(Arc::new(KvScanExec::new(
482            self.client.clone(),
483            self.model.clone(),
484            self.index_specs.clone(),
485            predicate,
486            limit,
487            projected_schema,
488            projection.cloned(),
489        )))
490    }
491
492    async fn insert_into(
493        &self,
494        _state: &dyn Session,
495        input: Arc<dyn ExecutionPlan>,
496        insert_op: InsertOp,
497    ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
498        self.schema()
499            .logically_equivalent_names_and_types(&input.schema())?;
500        if insert_op != InsertOp::Append {
501            return Err(DataFusionError::NotImplemented(format!(
502                "{insert_op} not implemented for kv table"
503            )));
504        }
505
506        let sink = KvIngestSink::new(
507            self.client.clone(),
508            self.model.schema.clone(),
509            self.model.clone(),
510            self.index_specs.clone(),
511        );
512        Ok(Arc::new(DataSinkExec::new(input, Arc::new(sink), None)))
513    }
514}