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
14const 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
26pub(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 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 let (tx, rx) = tokio::sync::mpsc::channel::<Result<RecordBatch, DataFusionError>>(64);
79
80 let task = tokio::spawn(async move {
81 const COALESCE_MAX_ROWS: usize = 1024;
87 let mut pending: Vec<RecordBatch> = Vec::new();
88 let mut pending_rows: usize = 0;
89 loop {
90 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 }
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; }
127 }
128 }
129 Ok(None) => {
130 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 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
158async 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
191pub(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 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
245pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}