1use std::sync::Arc;
4
5use arrow_array::{
6 Array, ArrayRef, BinaryArray, RecordBatch, RecordBatchOptions, UInt32Array, UInt8Array,
7};
8use arrow_schema::{DataType, Field, Schema, SchemaRef};
9
10use crate::checkpoint::{SourceCheckpoint, SourceCheckpointDelta};
11use crate::error::ConnectorError;
12
13use super::SourceRowPositionCapability;
14
15pub const SOURCE_PARTITION_COLUMN: &str = "__source_partition";
17pub const SOURCE_ORDER_KEY_COLUMN: &str = "__source_order_key";
19pub const SOURCE_SUB_OFFSET_COLUMN: &str = "__source_sub_offset";
21#[doc(hidden)]
23pub const SOURCE_MUTATION_COLUMN: &str = "__source_mutation";
24
25const SOURCE_POSITION_COLUMNS: [&str; 3] = [
26 SOURCE_PARTITION_COLUMN,
27 SOURCE_ORDER_KEY_COLUMN,
28 SOURCE_SUB_OFFSET_COLUMN,
29];
30const SOURCE_METADATA_COLUMNS: [&str; 4] = [
31 SOURCE_MUTATION_COLUMN,
32 SOURCE_PARTITION_COLUMN,
33 SOURCE_ORDER_KEY_COLUMN,
34 SOURCE_SUB_OFFSET_COLUMN,
35];
36
37pub fn schema_with_source_row_positions(schema: &SchemaRef) -> Result<SchemaRef, ConnectorError> {
42 schema_with_source_metadata(schema, false)
43}
44
45pub fn schema_with_source_mutations_and_row_positions(
50 schema: &SchemaRef,
51) -> Result<SchemaRef, ConnectorError> {
52 schema_with_source_metadata(schema, true)
53}
54
55fn schema_with_source_metadata(
56 schema: &SchemaRef,
57 include_mutations: bool,
58) -> Result<SchemaRef, ConnectorError> {
59 if let Some(field) = schema.fields().iter().find(|field| {
60 SOURCE_METADATA_COLUMNS
61 .iter()
62 .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
63 }) {
64 return Err(ConnectorError::SchemaMismatch(format!(
65 "source schema contains reserved metadata column '{}'",
66 field.name()
67 )));
68 }
69
70 let mut fields = schema.fields().to_vec();
71 if include_mutations {
72 fields.push(Arc::new(Field::new(
73 SOURCE_MUTATION_COLUMN,
74 DataType::UInt8,
75 false,
76 )));
77 }
78 fields.extend([
79 Arc::new(Field::new(SOURCE_PARTITION_COLUMN, DataType::Binary, false)),
80 Arc::new(Field::new(SOURCE_ORDER_KEY_COLUMN, DataType::Binary, false)),
81 Arc::new(Field::new(
82 SOURCE_SUB_OFFSET_COLUMN,
83 DataType::UInt32,
84 false,
85 )),
86 ]);
87 Ok(Arc::new(Schema::new_with_metadata(
88 fields,
89 schema.metadata().clone(),
90 )))
91}
92
93#[derive(Clone, Copy)]
94struct SourceMetadataLayout {
95 visible_columns: usize,
96 has_mutations: bool,
97}
98
99fn source_metadata_layout(schema: &Schema) -> Result<Option<SourceMetadataLayout>, ConnectorError> {
100 let fields = schema.fields();
101 if !fields.iter().any(|field| {
102 SOURCE_METADATA_COLUMNS
103 .iter()
104 .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
105 }) {
106 return Ok(None);
107 }
108
109 let position_start = fields
110 .len()
111 .checked_sub(SOURCE_POSITION_COLUMNS.len())
112 .ok_or_else(|| {
113 ConnectorError::SchemaMismatch(
114 "source metadata must end with the exact three row-position fields".into(),
115 )
116 })?;
117 let expected_positions = [
118 (SOURCE_PARTITION_COLUMN, DataType::Binary),
119 (SOURCE_ORDER_KEY_COLUMN, DataType::Binary),
120 (SOURCE_SUB_OFFSET_COLUMN, DataType::UInt32),
121 ];
122 for (field, (name, data_type)) in fields[position_start..].iter().zip(expected_positions) {
123 if field.name() != name || field.data_type() != &data_type || field.is_nullable() {
124 return Err(ConnectorError::SchemaMismatch(
125 "source metadata must end with the exact three typed row-position fields".into(),
126 ));
127 }
128 }
129
130 let has_mutations = position_start != 0
131 && fields[position_start - 1]
132 .name()
133 .eq_ignore_ascii_case(SOURCE_MUTATION_COLUMN);
134 let visible_columns = position_start - usize::from(has_mutations);
135 if has_mutations {
136 let field = &fields[visible_columns];
137 if field.name() != SOURCE_MUTATION_COLUMN
138 || field.data_type() != &DataType::UInt8
139 || field.is_nullable()
140 {
141 return Err(ConnectorError::SchemaMismatch(
142 "source mutation metadata must be the exact non-null UInt8 field immediately before row positions"
143 .into(),
144 ));
145 }
146 }
147 if fields[..visible_columns].iter().any(|field| {
148 SOURCE_METADATA_COLUMNS
149 .iter()
150 .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
151 }) {
152 return Err(ConnectorError::SchemaMismatch(
153 "source metadata fields are reserved and must use their exact trailing positions"
154 .into(),
155 ));
156 }
157
158 Ok(Some(SourceMetadataLayout {
159 visible_columns,
160 has_mutations,
161 }))
162}
163
164fn validate_source_position_arrays(
165 records: &RecordBatch,
166 layout: SourceMetadataLayout,
167) -> Result<(), ConnectorError> {
168 let position_start = layout.visible_columns + usize::from(layout.has_mutations);
169 let partition = records.columns()[position_start]
170 .as_any()
171 .downcast_ref::<BinaryArray>();
172 let order = records.columns()[position_start + 1]
173 .as_any()
174 .downcast_ref::<BinaryArray>();
175 let sub_offset = records.columns()[position_start + 2]
176 .as_any()
177 .downcast_ref::<UInt32Array>();
178 let (Some(partition), Some(order), Some(sub_offset)) = (partition, order, sub_offset) else {
179 return Err(ConnectorError::SchemaMismatch(
180 "source row-position metadata arrays have invalid types".into(),
181 ));
182 };
183 if partition.len() != records.num_rows()
184 || order.len() != records.num_rows()
185 || sub_offset.len() != records.num_rows()
186 {
187 return Err(ConnectorError::SchemaMismatch(
188 "source row-position metadata is not row-aligned".into(),
189 ));
190 }
191 if partition.null_count() != 0 || order.null_count() != 0 || sub_offset.null_count() != 0 {
192 return Err(ConnectorError::SchemaMismatch(
193 "source row-position metadata must not contain nulls".into(),
194 ));
195 }
196 Ok(())
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SourceRowPositionRef<'a> {
202 pub partition: &'a [u8],
204 pub order_key: &'a [u8],
206 pub sub_offset: u32,
208}
209
210#[derive(Debug, Clone, Copy)]
212pub struct SourceRowPositionView<'a> {
213 partition: &'a BinaryArray,
214 order_key: &'a BinaryArray,
215 sub_offset: &'a UInt32Array,
216}
217
218impl<'a> SourceRowPositionView<'a> {
219 #[must_use]
221 pub fn len(self) -> usize {
222 self.partition.len()
223 }
224
225 #[must_use]
227 pub fn is_empty(self) -> bool {
228 self.partition.is_empty()
229 }
230
231 #[must_use]
233 pub fn get(self, row: usize) -> Option<SourceRowPositionRef<'a>> {
234 (row < self.len()).then(|| SourceRowPositionRef {
235 partition: self.partition.value(row),
236 order_key: self.order_key.value(row),
237 sub_offset: self.sub_offset.value(row),
238 })
239 }
240}
241
242pub fn source_row_positions(
247 records: &RecordBatch,
248) -> Result<Option<SourceRowPositionView<'_>>, ConnectorError> {
249 let Some(layout) = source_metadata_layout(records.schema().as_ref())? else {
250 return Ok(None);
251 };
252 validate_source_position_arrays(records, layout)?;
253 let position_start = layout.visible_columns + usize::from(layout.has_mutations);
254 let (Some(partition), Some(order_key), Some(sub_offset)) = (
255 records.columns()[position_start]
256 .as_any()
257 .downcast_ref::<BinaryArray>(),
258 records.columns()[position_start + 1]
259 .as_any()
260 .downcast_ref::<BinaryArray>(),
261 records.columns()[position_start + 2]
262 .as_any()
263 .downcast_ref::<UInt32Array>(),
264 ) else {
265 return Err(ConnectorError::SchemaMismatch(
266 "source row-position metadata arrays have invalid types".into(),
267 ));
268 };
269 Ok(Some(SourceRowPositionView {
270 partition,
271 order_key,
272 sub_offset,
273 }))
274}
275
276fn validate_encoded_source_schema(
277 visible: &Schema,
278 encoded: &Schema,
279 expect_mutations: bool,
280) -> Result<(), ConnectorError> {
281 let layout = source_metadata_layout(encoded)?.ok_or_else(|| {
282 ConnectorError::SchemaMismatch("encoded source schema is missing row positions".into())
283 })?;
284 if layout.has_mutations != expect_mutations
285 || encoded.fields()[..layout.visible_columns] != visible.fields()[..]
286 || encoded.metadata() != visible.metadata()
287 {
288 return Err(ConnectorError::SchemaMismatch(
289 "encoded source schema does not match its visible schema and metadata contract".into(),
290 ));
291 }
292 Ok(())
293}
294
295#[derive(Debug, Clone, Copy)]
297pub struct SourceMutationView<'a> {
298 values: &'a UInt8Array,
299}
300
301impl SourceMutationView<'_> {
302 #[must_use]
304 pub fn len(self) -> usize {
305 self.values.len()
306 }
307
308 #[must_use]
310 pub fn is_empty(self) -> bool {
311 self.values.is_empty()
312 }
313
314 #[must_use]
316 pub fn get(self, row: usize) -> Option<SourceMutation> {
317 self.values.values().get(row).map(|value| match *value {
318 0 => SourceMutation::Put,
319 1 => SourceMutation::Tombstone,
320 _ => unreachable!("SourceMutationView is validated when constructed"),
321 })
322 }
323}
324
325pub fn source_mutations(
331 records: &RecordBatch,
332) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
333 source_mutations_validated(records, false)
334}
335
336pub fn source_mutations_routed(
344 records: &RecordBatch,
345) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
346 source_mutations_validated(records, true)
347}
348
349fn source_mutations_validated(
350 records: &RecordBatch,
351 allow_all_put: bool,
352) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
353 let Some(layout) = source_metadata_layout(records.schema().as_ref())? else {
354 return Ok(None);
355 };
356 validate_source_position_arrays(records, layout)?;
357 if !layout.has_mutations {
358 return Ok(None);
359 }
360 let mutations = records
361 .column(layout.visible_columns)
362 .as_any()
363 .downcast_ref::<UInt8Array>()
364 .ok_or_else(|| {
365 ConnectorError::SchemaMismatch("source mutation metadata array is not UInt8".into())
366 })?;
367 if mutations.len() != records.num_rows() {
368 return Err(ConnectorError::SchemaMismatch(format!(
369 "source mutation count {} does not match decoded row count {}",
370 mutations.len(),
371 records.num_rows()
372 )));
373 }
374 if mutations.null_count() != 0 {
375 return Err(ConnectorError::SchemaMismatch(
376 "source mutation metadata must not contain nulls".into(),
377 ));
378 }
379 let mut has_tombstone = false;
380 for &value in mutations.values() {
381 match value {
382 0 => {}
383 1 => has_tombstone = true,
384 value => {
385 return Err(ConnectorError::SchemaMismatch(format!(
386 "source mutation metadata contains unknown value {value}"
387 )));
388 }
389 }
390 }
391 if !has_tombstone && !allow_all_put {
392 return Err(ConnectorError::SchemaMismatch(
393 "all-put source mutations must omit the mutation metadata field".into(),
394 ));
395 }
396 Ok(Some(SourceMutationView { values: mutations }))
397}
398
399pub fn strip_source_mutations(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
404 strip_source_mutations_validated(records, false)
405}
406
407pub fn strip_source_mutations_routed(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
412 strip_source_mutations_validated(records, true)
413}
414
415fn strip_source_mutations_validated(
416 records: &RecordBatch,
417 allow_all_put: bool,
418) -> Result<RecordBatch, ConnectorError> {
419 let schema = records.schema();
420 let Some(layout) = source_metadata_layout(schema.as_ref())? else {
421 return Ok(records.clone());
422 };
423 source_mutations_validated(records, allow_all_put)?;
424 if !layout.has_mutations {
425 return Ok(records.clone());
426 }
427 let mut fields = schema.fields()[..layout.visible_columns].to_vec();
428 fields.extend(
429 schema.fields()[layout.visible_columns + 1..]
430 .iter()
431 .cloned(),
432 );
433 let mut columns = records.columns()[..layout.visible_columns].to_vec();
434 columns.extend(
435 records.columns()[layout.visible_columns + 1..]
436 .iter()
437 .cloned(),
438 );
439 let options = RecordBatchOptions::new().with_row_count(Some(records.num_rows()));
440 RecordBatch::try_new_with_options(
441 Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
442 columns,
443 &options,
444 )
445 .map_err(|error| {
446 ConnectorError::SchemaMismatch(format!("failed to strip source mutation metadata: {error}"))
447 })
448}
449
450pub fn strip_source_row_positions(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
458 let schema = records.schema();
459 let Some(layout) = source_metadata_layout(schema.as_ref())? else {
460 return Ok(records.clone());
461 };
462 source_mutations(records)?;
463 let visible_schema = Arc::new(Schema::new_with_metadata(
464 schema.fields()[..layout.visible_columns].to_vec(),
465 schema.metadata().clone(),
466 ));
467 let options = RecordBatchOptions::new().with_row_count(Some(records.num_rows()));
468 RecordBatch::try_new_with_options(
469 visible_schema,
470 records.columns()[..layout.visible_columns].to_vec(),
471 &options,
472 )
473 .map_err(|error| {
474 ConnectorError::SchemaMismatch(format!("failed to strip source metadata: {error}"))
475 })
476}
477
478#[derive(Debug, Clone)]
480pub struct SourceRowPositions {
481 partition: BinaryArray,
482 order_key: BinaryArray,
483 sub_offset: UInt32Array,
484}
485
486impl SourceRowPositions {
487 pub fn try_new(
492 partition: BinaryArray,
493 order_key: BinaryArray,
494 sub_offset: UInt32Array,
495 ) -> Result<Self, ConnectorError> {
496 let len = partition.len();
497 if order_key.len() != len || sub_offset.len() != len {
498 return Err(ConnectorError::SchemaMismatch(format!(
499 "source row-position arrays have different lengths: partition={len}, order={}, sub_offset={}",
500 order_key.len(),
501 sub_offset.len()
502 )));
503 }
504 if partition.null_count() != 0
505 || order_key.null_count() != 0
506 || sub_offset.null_count() != 0
507 {
508 return Err(ConnectorError::SchemaMismatch(
509 "source row-position arrays must not contain nulls".into(),
510 ));
511 }
512 Ok(Self {
513 partition,
514 order_key,
515 sub_offset,
516 })
517 }
518
519 fn len(&self) -> usize {
520 self.partition.len()
521 }
522
523 #[must_use]
525 pub const fn partition(&self) -> &BinaryArray {
526 &self.partition
527 }
528
529 #[must_use]
531 pub const fn order_key(&self) -> &BinaryArray {
532 &self.order_key
533 }
534
535 #[must_use]
537 pub const fn sub_offset(&self) -> &UInt32Array {
538 &self.sub_offset
539 }
540
541 fn validate_row_count(&self, rows: usize) -> Result<(), ConnectorError> {
542 if self.len() == rows {
543 Ok(())
544 } else {
545 Err(ConnectorError::SchemaMismatch(format!(
546 "source row-position count {} does not match decoded row count {rows}",
547 self.len()
548 )))
549 }
550 }
551
552 fn append_metadata(
553 self,
554 records: &RecordBatch,
555 encoded_schema: &SchemaRef,
556 mutations: Option<&[SourceMutation]>,
557 ) -> Result<RecordBatch, ConnectorError> {
558 self.validate_row_count(records.num_rows())?;
559 validate_encoded_source_schema(
560 records.schema().as_ref(),
561 encoded_schema.as_ref(),
562 mutations.is_some(),
563 )?;
564 if let Some(mutations) = mutations {
565 if mutations.len() != records.num_rows() {
566 return Err(ConnectorError::SchemaMismatch(format!(
567 "source mutation count {} does not match decoded row count {}",
568 mutations.len(),
569 records.num_rows()
570 )));
571 }
572 if !mutations.contains(&SourceMutation::Tombstone) {
573 return Err(ConnectorError::SchemaMismatch(
574 "all-put source mutations must omit the mutation metadata field".into(),
575 ));
576 }
577 }
578 let mut columns = records.columns().to_vec();
579 if let Some(mutations) = mutations {
580 columns.push(Arc::new(UInt8Array::from_iter_values(
581 mutations.iter().map(|mutation| match mutation {
582 SourceMutation::Put => 0,
583 SourceMutation::Tombstone => 1,
584 }),
585 )));
586 }
587 columns.extend([
588 Arc::new(self.partition) as ArrayRef,
589 Arc::new(self.order_key) as ArrayRef,
590 Arc::new(self.sub_offset) as ArrayRef,
591 ]);
592 RecordBatch::try_new(Arc::clone(encoded_schema), columns).map_err(|error| {
593 ConnectorError::SchemaMismatch(format!("failed to append source metadata: {error}"))
594 })
595 }
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum SourceMutation {
601 Put,
603 Tombstone,
605}
606
607#[derive(Debug, Clone)]
609pub struct SourceBatch {
610 pub records: RecordBatch,
612 row_positions: Option<SourceRowPositions>,
613 mutations: Option<Box<[SourceMutation]>>,
614 cursor: Option<SourceBatchCursor>,
615}
616
617#[derive(Debug, Clone)]
619pub enum SourceBatchCursor {
620 Complete(SourceCheckpoint),
622 Incremental(SourceCheckpointDelta),
624}
625
626impl SourceBatch {
627 #[must_use]
629 pub fn new(records: RecordBatch) -> Self {
630 Self {
631 records,
632 row_positions: None,
633 mutations: None,
634 cursor: None,
635 }
636 }
637
638 pub fn positioned(
643 records: RecordBatch,
644 row_positions: SourceRowPositions,
645 ) -> Result<Self, ConnectorError> {
646 row_positions.validate_row_count(records.num_rows())?;
647 Ok(Self {
648 records,
649 row_positions: Some(row_positions),
650 mutations: None,
651 cursor: None,
652 })
653 }
654
655 pub fn with_mutations(
663 mut self,
664 mutations: impl Into<Box<[SourceMutation]>>,
665 ) -> Result<Self, ConnectorError> {
666 let mutations = mutations.into();
667 if mutations.len() != self.records.num_rows() {
668 return Err(ConnectorError::SchemaMismatch(format!(
669 "source mutation count {} does not match decoded row count {}",
670 mutations.len(),
671 self.records.num_rows()
672 )));
673 }
674 self.mutations = mutations
675 .contains(&SourceMutation::Tombstone)
676 .then_some(mutations);
677 Ok(self)
678 }
679
680 #[must_use]
682 pub fn with_checkpoint(mut self, checkpoint: SourceCheckpoint) -> Self {
683 self.cursor = Some(SourceBatchCursor::Complete(checkpoint));
684 self
685 }
686
687 #[must_use]
689 pub fn with_checkpoint_delta(mut self, delta: SourceCheckpointDelta) -> Self {
690 self.cursor = Some(SourceBatchCursor::Incremental(delta));
691 self
692 }
693
694 pub fn take_cursor(&mut self) -> Option<SourceBatchCursor> {
696 self.cursor.take()
697 }
698
699 #[must_use]
701 pub const fn row_positions(&self) -> Option<&SourceRowPositions> {
702 self.row_positions.as_ref()
703 }
704
705 #[must_use]
707 pub fn mutations(&self) -> Option<&[SourceMutation]> {
708 self.mutations.as_deref()
709 }
710
711 pub fn into_records_with_metadata(
718 self,
719 capability: SourceRowPositionCapability,
720 positioned_schema: &SchemaRef,
721 mutation_schema: &SchemaRef,
722 ) -> Result<RecordBatch, ConnectorError> {
723 let Self {
724 records,
725 row_positions,
726 mutations,
727 cursor: _,
728 } = self;
729 match (capability, row_positions) {
730 (SourceRowPositionCapability::Unavailable, None) if mutations.is_none() => Ok(records),
731 (SourceRowPositionCapability::Unavailable, _) => Err(ConnectorError::SchemaMismatch(
732 "source emitted state metadata without declaring deterministic row positions"
733 .into(),
734 )),
735 (SourceRowPositionCapability::OrderedDeterministic, None) => {
736 Err(ConnectorError::SchemaMismatch(
737 "source declared deterministic row positions but omitted the sidecar".into(),
738 ))
739 }
740 (SourceRowPositionCapability::OrderedDeterministic, Some(positions)) => {
741 let schema = if mutations.is_some() {
742 mutation_schema
743 } else {
744 positioned_schema
745 };
746 positions.append_metadata(&records, schema, mutations.as_deref())
747 }
748 }
749 }
750
751 #[must_use]
753 pub fn num_rows(&self) -> usize {
754 self.records.num_rows()
755 }
756}