Skip to main content

krishiv_sql/
kafka_table.rs

1use arrow::datatypes::SchemaRef;
2use arrow::record_batch::RecordBatch;
3use datafusion::catalog::TableProvider;
4use datafusion::catalog::streaming::StreamingTable;
5use std::sync::Arc;
6
7use datafusion::error::{DataFusionError, Result as DataFusionResult};
8use datafusion::physical_plan::SendableRecordBatchStream;
9use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
10use datafusion::physical_plan::streaming::PartitionStream;
11use krishiv_connectors::Source;
12use krishiv_connectors::kafka::{KafkaConfig, KafkaSource};
13
14// Auto-commit interval for dev-local streaming SQL (at-least-once). Durable profiles
15// use manual commit aligned with checkpoint barriers.
16const STREAMING_AUTO_COMMIT_MS: u64 = 1_000;
17
18pub(crate) fn kafka_auto_commit_interval_ms() -> Option<u64> {
19    let profile = std::env::var("KRISHIV_DURABILITY_PROFILE")
20        .ok()
21        .and_then(|v| v.parse().ok())
22        .unwrap_or(krishiv_common::DurabilityProfile::DevLocal);
23    auto_commit_interval_for(profile)
24}
25
26/// The auto-commit decision itself, separated from reading the environment.
27///
28/// Kept pure so it can be tested directly: mutating process environment from
29/// a test is unsound under a multi-threaded runner, and the workspace denies
30/// the `unsafe` that edition 2024 requires for `set_var`.
31pub(crate) fn auto_commit_interval_for(
32    profile: krishiv_common::DurabilityProfile,
33) -> Option<u64> {
34    if krishiv_common::requires_manual_kafka_commit(profile) {
35        None
36    } else {
37        Some(STREAMING_AUTO_COMMIT_MS)
38    }
39}
40
41pub(crate) struct KafkaPartitionStream {
42    schema: SchemaRef,
43    source: Arc<tokio::sync::Mutex<KafkaSource>>,
44    /// Handle to the spawned Kafka consumer task; stored so it can be aborted
45    /// if the stream is dropped before the consumer loop exits.
46    consumer_task: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
47}
48
49impl KafkaPartitionStream {
50    pub fn new(schema: SchemaRef, source: KafkaSource) -> Self {
51        Self {
52            schema,
53            source: Arc::new(tokio::sync::Mutex::new(source)),
54            consumer_task: std::sync::Mutex::new(None),
55        }
56    }
57}
58
59impl std::fmt::Debug for KafkaPartitionStream {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("KafkaPartitionStream").finish()
62    }
63}
64
65impl PartitionStream for KafkaPartitionStream {
66    fn schema(&self) -> &SchemaRef {
67        &self.schema
68    }
69
70    fn execute(&self, _ctx: Arc<datafusion::execution::TaskContext>) -> SendableRecordBatchStream {
71        let source = self.source.clone();
72        let schema = self.schema.clone();
73        let manual_commit = kafka_auto_commit_interval_ms().is_none();
74
75        // Use an async channel so the polling loop can run indefinitely.
76        // `Ok(None)` from `read_batch` means "no message on this poll cycle"
77        // for an unbounded topic — we keep looping rather than ending the stream.
78        let (tx, rx) = tokio::sync::mpsc::channel::<Result<RecordBatch, DataFusionError>>(64);
79
80        let task = tokio::spawn(async move {
81            // Coalesce the source's per-message (typically single-row) batches
82            // into larger record batches for downstream throughput, while still
83            // flushing promptly on a poll gap so streaming latency stays low.
84            // Every projected batch shares the declared table schema, so
85            // concatenation is always valid.
86            const COALESCE_MAX_ROWS: usize = 1024;
87            let mut pending: Vec<RecordBatch> = Vec::new();
88            let mut pending_rows: usize = 0;
89            loop {
90                // Check cancellation before doing any I/O: if the DataFusion
91                // executor dropped the stream, stop immediately rather than
92                // waiting up to poll_timeout_ms to detect it on the next send.
93                if tx.is_closed() {
94                    break;
95                }
96                let res = {
97                    let mut guard = source.lock().await;
98                    guard.read_batch().await
99                };
100                match res {
101                    Ok(Some(batch)) if batch.num_rows() == 0 => {
102                        // Empty batch (tombstone / non-UTF-8 skip) — keep polling.
103                    }
104                    Ok(Some(batch)) => {
105                        match project_batch(&batch, &schema) {
106                            Ok(projected) => {
107                                pending_rows += projected.num_rows();
108                                pending.push(projected);
109                            }
110                            Err(e) => {
111                                let _ = flush_pending(&tx, &schema, &mut pending).await;
112                                let _ = tx
113                                    .send(Err(DataFusionError::ArrowError(Box::new(e), None)))
114                                    .await;
115                                break;
116                            }
117                        }
118                        if manual_commit {
119                            let guard = source.lock().await;
120                            guard.commit_current_offset();
121                        }
122                        if pending_rows >= COALESCE_MAX_ROWS {
123                            pending_rows = 0;
124                            if flush_pending(&tx, &schema, &mut pending).await.is_err() {
125                                break; // receiver dropped — query cancelled
126                            }
127                        }
128                    }
129                    Ok(None) => {
130                        // Poll gap — flush what we have so consumers see low
131                        // latency, then yield and retry.
132                        pending_rows = 0;
133                        if flush_pending(&tx, &schema, &mut pending).await.is_err() {
134                            break;
135                        }
136                        tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
137                    }
138                    Err(e) => {
139                        let _ = flush_pending(&tx, &schema, &mut pending).await;
140                        let _ = tx.send(Err(DataFusionError::External(Box::new(e)))).await;
141                        break;
142                    }
143                }
144            }
145            // Best-effort final flush before the task exits.
146            let _ = flush_pending(&tx, &schema, &mut pending).await;
147        });
148        *self.consumer_task.lock().unwrap_or_else(|p| p.into_inner()) = Some(task);
149
150        let recv_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
151        Box::pin(RecordBatchStreamAdapter::new(
152            self.schema.clone(),
153            recv_stream,
154        ))
155    }
156}
157
158/// Concatenate the buffered per-message batches into one and send it downstream.
159///
160/// All buffered batches share the declared table schema (they come from
161/// `project_batch`), so concatenation always succeeds. Returns `Err(())` if the
162/// receiver has been dropped (query cancelled) so the caller can stop polling.
163async fn flush_pending(
164    tx: &tokio::sync::mpsc::Sender<Result<RecordBatch, DataFusionError>>,
165    schema: &SchemaRef,
166    pending: &mut Vec<RecordBatch>,
167) -> Result<(), ()> {
168    if pending.is_empty() {
169        return Ok(());
170    }
171    let coalesced = if pending.len() == 1 {
172        pending.remove(0)
173    } else {
174        match arrow::compute::concat_batches(schema, pending.iter()) {
175            Ok(batch) => {
176                pending.clear();
177                batch
178            }
179            Err(e) => {
180                pending.clear();
181                return tx
182                    .send(Err(DataFusionError::ArrowError(Box::new(e), None)))
183                    .await
184                    .map_err(|_| ());
185            }
186        }
187    };
188    tx.send(Ok(coalesced)).await.map_err(|_| ())
189}
190
191/// Project and cast a raw connector batch to the declared table schema.
192///
193/// A column the message does not carry becomes a typed null array, so a
194/// sparse payload still matches the declared schema.
195///
196/// A value that is present but does not parse into the declared type becomes
197/// **null**: `arrow::compute::cast` is lenient by default, so casting the
198/// string `"not-a-number"` to `Int64` yields null rather than an error. That
199/// is a row silently losing a field, and this function warns when it happens
200/// — counting the nulls the cast introduced, since the kernel does not report
201/// them. Without the warning the loss is invisible, which is what "no silent
202/// data loss" was always supposed to rule out.
203///
204/// A type *pair* with no cast kernel at all is still a hard error, and the
205/// caller turns that into a terminated stream.
206pub(crate) fn project_batch(
207    batch: &RecordBatch,
208    schema: &SchemaRef,
209) -> Result<RecordBatch, arrow::error::ArrowError> {
210    let mut cols = Vec::with_capacity(schema.fields().len());
211    for field in schema.fields() {
212        let col = if let Ok(idx) = batch.schema().index_of(field.name()) {
213            let src = batch.column(idx);
214            let casted = arrow::compute::cast(src, field.data_type()).map_err(|e| {
215                arrow::error::ArrowError::CastError(format!(
216                    "Kafka column '{}': cast from {} to {} failed: {e}",
217                    field.name(),
218                    src.data_type(),
219                    field.data_type(),
220                ))
221            })?;
222            // A lenient cast reports nothing when it cannot parse a value — it
223            // just emits null. The only evidence is that nulls appeared where
224            // the source had none, so that is what we count.
225            let dropped = casted.null_count().saturating_sub(src.null_count());
226            if dropped > 0 {
227                tracing::warn!(
228                    column = %field.name(),
229                    from = %src.data_type(),
230                    to = %field.data_type(),
231                    dropped,
232                    "Kafka value(s) did not parse into the declared column type and \
233                     became null; the rows are kept and only this field is lost"
234                );
235            }
236            casted
237        } else {
238            arrow::array::new_null_array(field.data_type(), batch.num_rows())
239        };
240        cols.push(col);
241    }
242    RecordBatch::try_new(schema.clone(), cols)
243}
244
245/// Build a DataFusion `StreamingTable` backed by a live Kafka/Redpanda topic.
246///
247/// Enables rdkafka auto-commit at 1 s intervals for at-least-once delivery.
248/// Callers that prefer SQL DDL can use `CREATE EXTERNAL TABLE … STORED AS KAFKA`.
249pub fn create_kafka_streaming_table(
250    schema: SchemaRef,
251    config: KafkaConfig,
252) -> DataFusionResult<Arc<dyn TableProvider>> {
253    let config = match kafka_auto_commit_interval_ms() {
254        Some(ms) => config.with_auto_commit(ms),
255        None => config,
256    };
257    let source = KafkaSource::new(config).map_err(|e| DataFusionError::External(Box::new(e)))?;
258    let partition = Arc::new(KafkaPartitionStream::new(schema.clone(), source));
259    let table = StreamingTable::try_new(schema, vec![partition])?;
260    Ok(Arc::new(table))
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used, clippy::expect_used)]
265mod tests {
266    use super::*;
267    use arrow::array::{Array, Int32Array, Int64Array, StringArray};
268    use arrow::datatypes::{DataType, Field, Schema};
269
270    fn declared() -> SchemaRef {
271        Arc::new(Schema::new(vec![
272            Field::new("id", DataType::Int64, true),
273            Field::new("name", DataType::Utf8, true),
274        ]))
275    }
276
277    fn batch_of(fields: Vec<Field>, cols: Vec<arrow::array::ArrayRef>) -> RecordBatch {
278        RecordBatch::try_new(Arc::new(Schema::new(fields)), cols).unwrap()
279    }
280
281    /// A widening cast is applied, not merely accepted: the output must carry
282    /// the *declared* type, since the reduce side labels data with it.
283    #[test]
284    fn a_castable_column_is_cast_to_the_declared_type() {
285        let raw = batch_of(
286            vec![
287                Field::new("id", DataType::Int32, true),
288                Field::new("name", DataType::Utf8, true),
289            ],
290            vec![
291                Arc::new(Int32Array::from(vec![1, 2])),
292                Arc::new(StringArray::from(vec!["a", "b"])),
293            ],
294        );
295        let out = project_batch(&raw, &declared()).expect("castable");
296        assert_eq!(out.schema(), declared());
297        let ids = out
298            .column(0)
299            .as_any()
300            .downcast_ref::<Int64Array>()
301            .expect("cast to int64");
302        assert_eq!(ids.values(), &[1i64, 2]);
303    }
304
305    /// A column the message omits becomes typed nulls rather than a failure —
306    /// this is what lets a sparse payload satisfy the declared schema.
307    #[test]
308    fn a_missing_column_becomes_typed_nulls() {
309        let raw = batch_of(
310            vec![Field::new("id", DataType::Int64, true)],
311            vec![Arc::new(Int64Array::from(vec![7]))],
312        );
313        let out = project_batch(&raw, &declared()).expect("missing column is allowed");
314        assert_eq!(out.num_rows(), 1);
315        assert_eq!(out.column(1).null_count(), 1, "absent column must be null");
316        assert_eq!(out.schema(), declared());
317    }
318
319    /// A value that does not parse becomes null rather than failing the
320    /// batch — `arrow::compute::cast` is lenient — and the row survives with
321    /// only that field lost.
322    ///
323    /// This is the case the doc comment promised would not be *silent*. The
324    /// cast kernel reports nothing, so the only signal is the null that
325    /// appeared where the source had a value; `project_batch` counts exactly
326    /// that and warns.
327    #[test]
328    fn an_unparseable_value_becomes_null_and_keeps_the_row() {
329        let raw = batch_of(
330            vec![Field::new("id", DataType::Utf8, true)],
331            vec![Arc::new(StringArray::from(vec![Some("7"), Some("nope")]))],
332        );
333        let out = project_batch(&raw, &declared()).expect("lenient cast does not fail");
334        assert_eq!(out.num_rows(), 2, "both rows must survive");
335        let ids = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
336        assert!(!ids.is_null(0), "the parseable value is kept");
337        assert_eq!(ids.value(0), 7);
338        assert!(ids.is_null(1), "the unparseable value becomes null");
339    }
340
341    /// Columns are selected by name, so a different message field order is
342    /// not a reordering of the output.
343    #[test]
344    fn columns_are_matched_by_name_not_position() {
345        let raw = batch_of(
346            vec![
347                Field::new("name", DataType::Utf8, true),
348                Field::new("id", DataType::Int64, true),
349            ],
350            vec![
351                Arc::new(StringArray::from(vec!["z"])),
352                Arc::new(Int64Array::from(vec![9])),
353            ],
354        );
355        let out = project_batch(&raw, &declared()).expect("reordered");
356        let ids = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
357        assert_eq!(ids.values(), &[9i64], "id must come from the 'id' field");
358    }
359
360    /// Fields the table did not declare are dropped.
361    #[test]
362    fn undeclared_columns_are_ignored() {
363        let raw = batch_of(
364            vec![
365                Field::new("id", DataType::Int64, true),
366                Field::new("name", DataType::Utf8, true),
367                Field::new("extra", DataType::Utf8, true),
368            ],
369            vec![
370                Arc::new(Int64Array::from(vec![1])),
371                Arc::new(StringArray::from(vec!["a"])),
372                Arc::new(StringArray::from(vec!["ignored"])),
373            ],
374        );
375        let out = project_batch(&raw, &declared()).expect("extra column");
376        assert_eq!(out.num_columns(), 2);
377        assert_eq!(out.schema(), declared());
378    }
379
380    #[tokio::test]
381    async fn flushing_nothing_sends_nothing() {
382        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
383        let mut pending = Vec::new();
384        flush_pending(&tx, &declared(), &mut pending).await.unwrap();
385        drop(tx);
386        assert!(rx.recv().await.is_none(), "an empty flush must not send");
387    }
388
389    /// Several buffered messages arrive downstream as one batch, with every
390    /// row preserved and in order — that is the whole point of coalescing.
391    #[tokio::test]
392    async fn flushing_coalesces_into_one_batch_preserving_order() {
393        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
394        let schema = declared();
395        let mut pending: Vec<RecordBatch> = (0..3)
396            .map(|i| {
397                RecordBatch::try_new(
398                    schema.clone(),
399                    vec![
400                        Arc::new(Int64Array::from(vec![i])),
401                        Arc::new(StringArray::from(vec![format!("r{i}")])),
402                    ],
403                )
404                .unwrap()
405            })
406            .collect();
407        flush_pending(&tx, &schema, &mut pending).await.unwrap();
408        assert!(pending.is_empty(), "flush must drain the buffer");
409
410        let got = rx.recv().await.expect("one batch").expect("ok");
411        assert_eq!(got.num_rows(), 3, "three messages must arrive as three rows");
412        let ids = got.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
413        assert_eq!(ids.values(), &[0i64, 1, 2], "order must be preserved");
414    }
415
416    /// A dropped receiver is how a cancelled query reaches the consumer loop;
417    /// the flush must report it so polling stops instead of spinning forever.
418    #[tokio::test]
419    async fn flushing_to_a_dropped_receiver_reports_the_cancellation() {
420        let (tx, rx) = tokio::sync::mpsc::channel(4);
421        drop(rx);
422        let schema = declared();
423        let mut pending = vec![
424            RecordBatch::try_new(
425                schema.clone(),
426                vec![
427                    Arc::new(Int64Array::from(vec![1])),
428                    Arc::new(StringArray::from(vec!["a"])),
429                ],
430            )
431            .unwrap(),
432        ];
433        assert!(
434            flush_pending(&tx, &schema, &mut pending).await.is_err(),
435            "a dropped receiver must surface as an error so the loop breaks"
436        );
437    }
438
439    /// A profile that owns its offsets must not also have rdkafka committing
440    /// behind its back: both durable profiles align commits with checkpoint
441    /// barriers, so auto-commit has to be off unconditionally.
442    #[test]
443    fn durable_profiles_disable_auto_commit() {
444        use krishiv_common::DurabilityProfile;
445        for profile in [
446            DurabilityProfile::SingleNodeDurable,
447            DurabilityProfile::DistributedDurable,
448        ] {
449            assert_eq!(
450                auto_commit_interval_for(profile),
451                None,
452                "{profile:?} commits on checkpoint barriers, not on a timer"
453            );
454        }
455    }
456
457    /// Dev-local is at-least-once via auto-commit — but only when the process
458    /// is not in production mode, since `requires_manual_kafka_commit`
459    /// consults that too. Asserting a fixed value here would make the test
460    /// depend on ambient environment, so it pins the interval only in the
461    /// case where auto-commit applies at all.
462    #[test]
463    fn dev_local_auto_commit_uses_the_documented_interval() {
464        use krishiv_common::DurabilityProfile;
465        if let Some(ms) = auto_commit_interval_for(DurabilityProfile::DevLocal) {
466            assert_eq!(ms, STREAMING_AUTO_COMMIT_MS);
467        }
468    }
469}