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 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 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 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 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 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 = ¤t_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}