1use std::collections::{BTreeMap, HashSet};
21use std::pin::Pin;
22use std::sync::Arc;
23
24use anyhow::Context;
25use futures::stream::{self, BoxStream, Peekable};
26use futures::{StreamExt, TryStreamExt};
27use futures_async_stream::try_stream;
28use iceberg::scan::{FileScanTask, FileScanTaskDeleteFile};
29use iceberg::spec::{
30 DEFAULT_SCHEMA_NAME_MAPPING, DataContentType, DataFileFormat, Datum, FormatVersion, Literal,
31 Manifest, ManifestContentType, ManifestEntry, ManifestFile, ManifestList, NameMapping,
32 Operation, PartitionSpec, PrimitiveLiteral, PrimitiveType, SchemaRef, SnapshotRef, Struct,
33 Type,
34};
35use iceberg::table::Table;
36use risingwave_common::catalog::ColumnCatalog;
37use risingwave_common::util::iter_util::ZipEqFast;
38use risingwave_common::{bail, ensure};
39use serde::{Deserialize, Serialize};
40use uuid::Uuid;
41
42use super::update_reader::IcebergUpdateReadMode;
43use super::validate_update_projection;
44use crate::error::{ConnectorError, ConnectorResult};
45use crate::sink::iceberg::validate_position_delete_descriptor;
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct IcebergUpdateBinding {
49 table_uuid: Uuid,
50 bootstrap_snapshot_id: Option<i64>,
51 schema: SchemaRef,
52 project_field_ids: Vec<i32>,
53 name_mapping: Option<Arc<NameMapping>>,
54}
55
56impl IcebergUpdateBinding {
57 pub fn bind_columns(table: &Table, columns: &[ColumnCatalog]) -> ConnectorResult<Self> {
59 let schema = table.metadata().current_schema();
60 let field_ids = columns
61 .iter()
62 .map(|column| {
63 schema
64 .as_struct()
65 .fields()
66 .iter()
67 .find(|field| field.name == column.name())
68 .map(|field| field.id)
69 .with_context(|| format!("Iceberg stored column not found: {}", column.name()))
70 })
71 .collect::<anyhow::Result<Vec<_>>>()?;
72 Self::bind(table, field_ids)
73 }
74
75 pub fn validate_columns(&self, columns: &[ColumnCatalog]) -> ConnectorResult<()> {
76 ensure!(
77 self.project_field_ids.len() == columns.len()
78 && self
79 .project_field_ids
80 .iter()
81 .zip_eq_fast(columns)
82 .all(|(id, column)| {
83 self.schema
84 .field_by_id(*id)
85 .is_some_and(|field| field.name == column.name())
86 }),
87 "Iceberg stored projection differs from the source columns"
88 );
89 Ok(())
90 }
91
92 pub fn bind(table: &Table, project_field_ids: Vec<i32>) -> ConnectorResult<Self> {
93 let metadata = table.metadata();
94 let schema = metadata.current_schema().clone();
95 let binding = Self {
96 table_uuid: metadata.uuid(),
97 bootstrap_snapshot_id: metadata.current_snapshot_id(),
98 schema,
99 project_field_ids,
100 name_mapping: read_name_mapping(table)?,
101 };
102 binding.validate(table)?;
103 if let Some(id) = binding.bootstrap_snapshot_id {
104 binding.snapshot(table, id)?;
105 }
106 Ok(binding)
107 }
108
109 fn validate(&self, table: &Table) -> ConnectorResult<()> {
110 let metadata = table.metadata();
111 ensure!(
112 metadata.uuid() == self.table_uuid,
113 "Iceberg table UUID changed"
114 );
115 ensure!(
116 matches!(
117 metadata.format_version(),
118 FormatVersion::V2 | FormatVersion::V3
119 ),
120 "delete-aware source requires Iceberg V2 or V3"
121 );
122 ensure!(
123 metadata.current_snapshot_id()
124 == metadata
125 .snapshot_for_ref("main")
126 .map(|snapshot| snapshot.snapshot_id()),
127 "delete-aware source requires the main branch"
128 );
129 ensure!(
130 *metadata.current_schema() == self.schema,
131 "Iceberg source schema changed"
132 );
133 ensure!(
134 read_name_mapping(table)? == self.name_mapping,
135 "Iceberg name mapping changed"
136 );
137 validate_update_projection(&self.schema, &self.project_field_ids)?;
138 Ok(())
139 }
140
141 fn snapshot<'a>(&self, table: &'a Table, id: i64) -> ConnectorResult<&'a SnapshotRef> {
142 let snapshot = table
143 .metadata()
144 .snapshot_by_id(id)
145 .context("required Iceberg snapshot has expired or is missing")?;
146 ensure!(
147 snapshot.schema(table.metadata())? == self.schema,
148 "Iceberg snapshot schema changed"
149 );
150 ensure!(
151 snapshot.encryption_key_id().is_none(),
152 "encrypted snapshots are unsupported"
153 );
154 Ok(snapshot)
155 }
156
157 fn pending<'a>(
160 &self,
161 table: &'a Table,
162 last: Option<i64>,
163 ) -> ConnectorResult<Vec<&'a SnapshotRef>> {
164 if let Some(id) = last {
165 self.snapshot(table, id)?;
166 }
167 let mut cursor = table.metadata().current_snapshot_id();
168 let mut pending = vec![];
169 let mut seen = HashSet::new();
170 while cursor != last {
171 let id =
172 cursor.context("Iceberg history rolled back or diverged from source progress")?;
173 ensure!(seen.insert(id), "cycle in Iceberg snapshot ancestry");
174 let snapshot = self.snapshot(table, id)?;
175 pending.push(snapshot);
176 cursor = snapshot.parent_snapshot_id();
177 }
178 pending.reverse();
179 Ok(pending)
180 }
181
182 fn ensure_ancestor(&self, table: &Table, last: Option<i64>) -> ConnectorResult<()> {
183 self.pending(table, last).map(drop)
184 }
185}
186
187fn is_compaction(snapshot: &SnapshotRef) -> bool {
188 snapshot.summary().operation == Operation::Replace
189}
190
191fn range_len(pending: &[&SnapshotRef]) -> usize {
195 match pending.first() {
196 None => 0,
197 Some(first) if is_compaction(first) => 1,
198 Some(_) => pending
199 .iter()
200 .take_while(|snapshot| !is_compaction(snapshot))
201 .count(),
202 }
203}
204
205fn read_name_mapping(table: &Table) -> ConnectorResult<Option<Arc<NameMapping>>> {
206 table
207 .metadata()
208 .properties()
209 .get(DEFAULT_SCHEMA_NAME_MAPPING)
210 .map(|value| {
211 serde_json::from_str(value)
212 .map(Arc::new)
213 .map_err(Into::into)
214 })
215 .transpose()
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum IcebergUpdatePhase {
220 Delete,
221 Insert,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct IcebergUpdateTaskId {
227 pub table_uuid: Uuid,
228 pub snapshot_id: i64,
229 pub phase: IcebergUpdatePhase,
230 pub data_file_path: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236struct UpdateFile {
237 path: String,
238 size: u64,
239 record_count: u64,
240 sequence_number: i64,
241 file_sequence_number: Option<i64>,
242 first_row_id: Option<i64>,
243 partition: serde_json::Value,
244 partition_spec: Arc<PartitionSpec>,
245 deletes: Vec<FileScanTaskDeleteFile>,
246}
247
248impl UpdateFile {
249 fn same_data_file(&self, other: &Self) -> bool {
252 let Self {
253 path,
254 size,
255 record_count,
256 sequence_number,
257 file_sequence_number,
258 first_row_id: _,
259 partition,
260 partition_spec,
261 deletes: _,
262 } = self;
263 *path == other.path
264 && *size == other.size
265 && *record_count == other.record_count
266 && *sequence_number == other.sequence_number
267 && *file_sequence_number == other.file_sequence_number
268 && *partition == other.partition
269 && *partition_spec == other.partition_spec
270 }
271
272 fn scan_task(&self, binding: &IcebergUpdateBinding) -> ConnectorResult<FileScanTask> {
273 let partition_type = Type::Struct(self.partition_spec.partition_type(&binding.schema)?);
274 let Some(Literal::Struct(partition)) =
275 Literal::try_from_json(self.partition.clone(), &partition_type)?
276 else {
277 bail!("invalid persisted update-task partition");
278 };
279 Ok(FileScanTask::builder()
280 .with_file_size_in_bytes(self.size)
281 .with_start(0)
282 .with_length(self.size)
283 .with_record_count(Some(self.record_count))
284 .with_data_file_path(self.path.clone())
285 .with_data_file_format(DataFileFormat::Parquet)
286 .with_schema(binding.schema.clone())
287 .with_project_field_ids(binding.project_field_ids.clone())
288 .with_sequence_number(self.sequence_number)
289 .with_data_sequence_number(Some(self.sequence_number))
290 .with_file_sequence_number(self.file_sequence_number)
291 .with_first_row_id(self.first_row_id)
292 .with_partition(Some(partition))
293 .with_partition_spec(Some(self.partition_spec.clone()))
294 .with_name_mapping(binding.name_mapping.clone())
295 .with_deletes(self.deletes.clone())
296 .with_case_sensitive(true)
297 .build())
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct IcebergUpdateTask {
303 pub id: IcebergUpdateTaskId,
304 pub parent_snapshot_id: Option<i64>,
306 binding: Arc<IcebergUpdateBinding>,
307 file: UpdateFile,
308 parent_deletes: Vec<FileScanTaskDeleteFile>,
309 #[serde(default)]
311 removed: bool,
312}
313
314impl IcebergUpdateTask {
315 pub fn validate_columns(&self, columns: &[ColumnCatalog]) -> ConnectorResult<()> {
316 self.binding.validate_columns(columns)
317 }
318
319 pub fn validate_key(
320 &self,
321 columns: &[ColumnCatalog],
322 key_indices: &[usize],
323 ) -> ConnectorResult<()> {
324 self.validate_columns(columns)?;
325 let mut seen = HashSet::new();
326 ensure!(
327 !key_indices.is_empty()
328 && key_indices
329 .iter()
330 .all(|index| *index < columns.len() && seen.insert(*index)),
331 "Iceberg update source requires a valid SQL primary key"
332 );
333 Ok(())
334 }
335
336 pub fn record_count(&self) -> u64 {
337 self.file.record_count
338 }
339
340 pub fn prepare_read(
343 &self,
344 table: &Table,
345 ) -> ConnectorResult<(FileScanTask, IcebergUpdateReadMode)> {
346 self.binding.validate(table)?;
347 self.binding
348 .ensure_ancestor(table, Some(self.id.snapshot_id))?;
349 if let Some(parent) = self.parent_snapshot_id {
350 self.binding.snapshot(table, parent)?;
351 }
352 ensure!(
353 self.id.table_uuid == self.binding.table_uuid
354 && self.id.data_file_path == self.file.path,
355 "update task identity differs from its persisted descriptors"
356 );
357 let mode = match self.id.phase {
358 IcebergUpdatePhase::Insert => IcebergUpdateReadMode::Insert,
359 IcebergUpdatePhase::Delete if self.removed => IcebergUpdateReadMode::Remove,
360 IcebergUpdatePhase::Delete => IcebergUpdateReadMode::Delete {
361 parent_deletes: self.parent_deletes.clone(),
362 },
363 };
364 Ok((self.file.scan_task(&self.binding)?, mode))
365 }
366}
367
368#[cfg(test)]
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371pub struct IcebergUpdatePlan {
372 pub snapshot_id: i64,
373 pub parent_snapshot_id: Option<i64>,
374 pub deletes: Vec<IcebergUpdateTask>,
375 pub inserts: Vec<IcebergUpdateTask>,
376}
377
378pub struct IcebergUpdatePlanner {
379 binding: Arc<IcebergUpdateBinding>,
380 enumeration: Option<(
383 IcebergUpdateCursor,
384 Peekable<BoxStream<'static, ConnectorResult<IcebergUpdateTask>>>,
385 )>,
386}
387
388impl IcebergUpdatePlanner {
389 pub fn new(binding: IcebergUpdateBinding) -> Self {
390 Self {
391 binding: Arc::new(binding),
392 enumeration: None,
393 }
394 }
395
396 #[cfg(test)]
397 pub async fn plan_bootstrap(
398 &self,
399 table: &Table,
400 ) -> ConnectorResult<Option<IcebergUpdatePlan>> {
401 let Some(cursor) = self.start_cursor(table, false, None)? else {
402 return Ok(None);
403 };
404 self.collect_plan(table, cursor).await.map(Some)
405 }
406
407 #[cfg(test)]
408 pub async fn plan_next(
409 &self,
410 table: &Table,
411 last_snapshot: Option<i64>,
412 ) -> ConnectorResult<Option<IcebergUpdatePlan>> {
413 ensure!(
414 last_snapshot.is_some() || self.binding.bootstrap_snapshot_id.is_none(),
415 "bootstrap must finish before incremental planning"
416 );
417 let Some(cursor) = self.start_cursor(table, true, last_snapshot)? else {
418 return Ok(None);
419 };
420 self.collect_plan(table, cursor).await.map(Some)
421 }
422
423 #[cfg(test)]
424 async fn collect_plan(
425 &self,
426 table: &Table,
427 mut cursor: IcebergUpdateCursor,
428 ) -> ConnectorResult<IcebergUpdatePlan> {
429 let mut plan = IcebergUpdatePlan {
430 snapshot_id: cursor.snapshot_id,
431 parent_snapshot_id: cursor.parent_snapshot_id,
432 deletes: vec![],
433 inserts: vec![],
434 };
435 if !cursor.bootstrap {
436 plan.deletes = stream_tasks(self.binding.clone(), table.clone(), cursor.clone())
437 .try_collect()
438 .await?;
439 }
440 cursor.phase = IcebergUpdatePhase::Insert;
441 plan.inserts = stream_tasks(self.binding.clone(), table.clone(), cursor)
442 .try_collect()
443 .await?;
444 for tasks in [&mut plan.deletes, &mut plan.inserts] {
446 tasks.sort_unstable_by(|left, right| {
447 left.id.data_file_path.cmp(&right.id.data_file_path)
448 });
449 }
450 Ok(plan)
451 }
452}
453
454fn task(
455 binding: &Arc<IcebergUpdateBinding>,
456 cursor: &IcebergUpdateCursor,
457 file: UpdateFile,
458 parent_deletes: Vec<FileScanTaskDeleteFile>,
459 removed: bool,
460) -> IcebergUpdateTask {
461 IcebergUpdateTask {
462 id: IcebergUpdateTaskId {
463 table_uuid: binding.table_uuid,
464 snapshot_id: cursor.snapshot_id,
465 phase: cursor.phase,
466 data_file_path: file.path.clone(),
467 },
468 parent_snapshot_id: cursor.parent_snapshot_id,
469 binding: binding.clone(),
470 file,
471 parent_deletes,
472 removed,
473 }
474}
475
476fn stream_tasks(
480 binding: Arc<IcebergUpdateBinding>,
481 table: Table,
482 cursor: IcebergUpdateCursor,
483) -> BoxStream<'static, ConnectorResult<IcebergUpdateTask>> {
484 if cursor.bootstrap {
485 let task_binding = binding.clone();
486 stream_files(binding, table, cursor.snapshot_id)
487 .map_ok(move |file| task(&task_binding, &cursor, file, vec![], false))
488 .boxed()
489 } else {
490 match cursor.phase {
491 IcebergUpdatePhase::Delete => delete_tasks(binding, table, cursor),
492 IcebergUpdatePhase::Insert => insert_tasks(binding, table, cursor),
493 }
494 }
495}
496
497struct ManifestDiff {
508 current: SnapshotRef,
509 parent_sequence: i64,
510 added: Vec<ManifestFile>,
512 removed: Vec<ManifestFile>,
514 unchanged: Vec<ManifestFile>,
516}
517
518impl ManifestDiff {
519 async fn load(
520 binding: &IcebergUpdateBinding,
521 table: &Table,
522 cursor: &IcebergUpdateCursor,
523 ) -> ConnectorResult<Self> {
524 let cache = table.object_cache();
525 let metadata = table.metadata_ref();
526 let current = binding.snapshot(table, cursor.snapshot_id)?.clone();
527 let current_list = cache.get_manifest_list(¤t, &metadata).await?;
528 let (parent_list, parent_sequence): (Option<Arc<ManifestList>>, _) =
529 match cursor.parent_snapshot_id {
530 Some(id) => {
531 let parent = binding.snapshot(table, id)?;
532 (
533 Some(cache.get_manifest_list(parent, &metadata).await?),
534 parent.sequence_number(),
535 )
536 }
537 None => (None, 0),
538 };
539 let parent_entries = parent_list
540 .as_deref()
541 .map_or(&[][..], ManifestList::entries);
542 let parent_paths: HashSet<&str> = parent_entries
543 .iter()
544 .map(|file| file.manifest_path.as_str())
545 .collect();
546 let current_paths: HashSet<&str> = current_list
547 .entries()
548 .iter()
549 .map(|file| file.manifest_path.as_str())
550 .collect();
551 let (unchanged, added): (Vec<_>, Vec<_>) = current_list
552 .entries()
553 .iter()
554 .cloned()
555 .partition(|file| parent_paths.contains(file.manifest_path.as_str()));
556 let removed = parent_entries
557 .iter()
558 .filter(|file| !current_paths.contains(file.manifest_path.as_str()))
559 .cloned()
560 .collect();
561 Ok(Self {
562 current,
563 parent_sequence,
564 added,
565 removed,
566 unchanged,
567 })
568 }
569
570 fn is_compaction(&self) -> bool {
572 is_compaction(&self.current)
573 }
574
575 fn current_sequence(&self) -> i64 {
576 self.current.sequence_number()
577 }
578
579 async fn validate(&self, binding: &IcebergUpdateBinding, table: &Table) -> ConnectorResult<()> {
581 for (manifests, sequence) in [
582 (&self.removed, self.parent_sequence),
583 (&self.added, self.current_sequence()),
584 ] {
585 let deletes = index_deletes(
586 binding,
587 table,
588 of_content(manifests, ManifestContentType::Deletes),
589 sequence,
590 )
591 .await?;
592 let mut paths = HashSet::new();
593 for manifest in of_content(manifests, ManifestContentType::Data) {
594 for file in load_data_files(binding, table, manifest, sequence).await? {
595 attached_deletes(&file, deletes.get(&file.path))?;
596 ensure!(paths.insert(file.path), "duplicate live data-file path");
597 }
598 }
599 }
600 Ok(())
601 }
602}
603
604fn of_content(
605 manifests: &[ManifestFile],
606 content: ManifestContentType,
607) -> impl Iterator<Item = &ManifestFile> {
608 manifests.iter().filter(move |file| file.content == content)
609}
610
611#[try_stream(boxed, ok = IcebergUpdateTask, error = ConnectorError)]
615async fn delete_tasks(
616 binding: Arc<IcebergUpdateBinding>,
617 table: Table,
618 cursor: IcebergUpdateCursor,
619) {
620 let diff = ManifestDiff::load(&binding, &table, &cursor).await?;
621 if diff.is_compaction() {
622 diff.validate(&binding, &table).await?;
624 return Ok(());
625 }
626 let mut removed = BTreeMap::new();
627 for manifest in of_content(&diff.removed, ManifestContentType::Data) {
628 for file in load_data_files(&binding, &table, manifest, diff.parent_sequence).await? {
629 ensure!(
630 removed.insert(file.path.clone(), file).is_none(),
631 "duplicate live data-file path"
632 );
633 }
634 }
635 let parent_deletes = index_deletes(
636 &binding,
637 &table,
638 of_content(&diff.removed, ManifestContentType::Deletes),
639 diff.parent_sequence,
640 )
641 .await?;
642 let current_deletes = index_deletes(
643 &binding,
644 &table,
645 of_content(&diff.added, ManifestContentType::Deletes),
646 diff.current_sequence(),
647 )
648 .await?;
649
650 let mut added = HashSet::new();
651 for manifest in of_content(&diff.added, ManifestContentType::Data) {
652 for file in load_data_files(&binding, &table, manifest, diff.current_sequence()).await? {
653 ensure!(
654 added.insert(file.path.clone()),
655 "duplicate live data-file path"
656 );
657 if let Some(old) = removed.remove(&file.path) {
659 ensure!(
660 old.same_data_file(&file),
661 "retained immutable data-file metadata changed"
662 );
663 if let Some(task) =
664 delete_change(&binding, &cursor, file, &parent_deletes, ¤t_deletes)?
665 {
666 yield task;
667 }
668 }
669 }
670 }
671
672 let mut changed: BTreeMap<&str, Vec<(i32, &Struct)>> = BTreeMap::new();
675 for (path, entry) in parent_deletes.iter().chain(¤t_deletes) {
676 if !added.contains(path)
677 && !removed.contains_key(path)
678 && parent_deletes.get(path).map(|entry| &entry.delete)
679 != current_deletes.get(path).map(|entry| &entry.delete)
680 {
681 changed
682 .entry(path.as_str())
683 .or_default()
684 .push((entry.delete.partition_spec_id, &entry.partition));
685 }
686 }
687
688 let mut dangling = BTreeMap::new();
690 let mut unresolved: HashSet<&str> = removed
691 .keys()
692 .map(String::as_str)
693 .filter(|path| !parent_deletes.contains_key(*path))
694 .collect();
695 for manifest in of_content(&diff.unchanged, ManifestContentType::Deletes) {
696 if unresolved.is_empty() {
697 break;
698 }
699 for (path, entry) in load_deletes(&binding, &table, manifest, diff.parent_sequence).await? {
700 if unresolved.remove(path.as_str()) {
701 dangling.insert(path, entry);
702 }
703 }
704 }
705 drop(unresolved);
706 for (path, mut file) in removed {
707 let parent = parent_deletes.get(&path).or_else(|| dangling.get(&path));
708 file.deletes = attached_deletes(&file, parent)?;
709 yield task(&binding, &cursor, file, vec![], true);
710 }
711
712 for manifest in of_content(&diff.unchanged, ManifestContentType::Data) {
713 if changed.is_empty() {
714 break;
715 }
716 if !changed
717 .values()
718 .flatten()
719 .any(|(spec_id, partition)| may_contain_partition(manifest, *spec_id, partition))
720 {
721 continue;
722 }
723 for file in load_data_files(&binding, &table, manifest, diff.parent_sequence).await? {
724 if changed.remove(file.path.as_str()).is_some()
725 && let Some(task) =
726 delete_change(&binding, &cursor, file, &parent_deletes, ¤t_deletes)?
727 {
728 yield task;
729 }
730 }
731 }
732 }
734
735#[try_stream(boxed, ok = IcebergUpdateTask, error = ConnectorError)]
738async fn insert_tasks(
739 binding: Arc<IcebergUpdateBinding>,
740 table: Table,
741 cursor: IcebergUpdateCursor,
742) {
743 let diff = ManifestDiff::load(&binding, &table, &cursor).await?;
744 if diff.is_compaction()
745 || of_content(&diff.added, ManifestContentType::Data)
746 .next()
747 .is_none()
748 {
749 return Ok(());
750 }
751 let mut parent_paths = HashSet::new();
752 for manifest in of_content(&diff.removed, ManifestContentType::Data) {
753 for file in load_data_files(&binding, &table, manifest, diff.parent_sequence).await? {
754 parent_paths.insert(file.path);
755 }
756 }
757 let deletes = index_deletes(
758 &binding,
759 &table,
760 of_content(&diff.added, ManifestContentType::Deletes),
761 diff.current_sequence(),
762 )
763 .await?;
764 let mut seen = HashSet::new();
765 for manifest in of_content(&diff.added, ManifestContentType::Data) {
766 for mut file in load_data_files(&binding, &table, manifest, diff.current_sequence()).await?
767 {
768 ensure!(
769 seen.insert(file.path.clone()),
770 "duplicate live data-file path"
771 );
772 if parent_paths.contains(&file.path) {
773 continue;
774 }
775 file.deletes = attached_deletes(&file, deletes.get(&file.path))?;
776 yield task(&binding, &cursor, file, vec![], false);
777 }
778 }
779}
780
781fn delete_change(
782 binding: &Arc<IcebergUpdateBinding>,
783 cursor: &IcebergUpdateCursor,
784 mut file: UpdateFile,
785 parent: &DeleteIndex,
786 current: &DeleteIndex,
787) -> ConnectorResult<Option<IcebergUpdateTask>> {
788 let parent_deletes = attached_deletes(&file, parent.get(&file.path))?;
789 file.deletes = attached_deletes(&file, current.get(&file.path))?;
790 Ok(
791 (parent_deletes != file.deletes)
792 .then(|| task(binding, cursor, file, parent_deletes, false)),
793 )
794}
795
796fn attached_deletes(
797 file: &UpdateFile,
798 delete: Option<&DeleteEntry>,
799) -> ConnectorResult<Vec<FileScanTaskDeleteFile>> {
800 let Some(entry) = delete else {
801 return Ok(vec![]);
802 };
803 ensure!(
804 entry.delete.sequence_number >= file.sequence_number,
805 "delete predates its referenced data file"
806 );
807 Ok(vec![entry.delete.clone()])
808}
809
810fn may_contain_partition(manifest: &ManifestFile, spec_id: i32, partition: &Struct) -> bool {
814 if manifest.partition_spec_id != spec_id {
815 return true;
816 }
817 let Some(summaries) = &manifest.partitions else {
818 return true;
819 };
820 if summaries.len() != partition.fields().len() {
821 return true;
822 }
823 summaries
824 .iter()
825 .zip_eq_fast(partition.iter())
826 .all(|(summary, value)| {
827 let value = match value {
828 None => return summary.contains_null,
829 Some(Literal::Primitive(value)) => value,
830 Some(_) => return true,
831 };
832 let ty = match value {
834 PrimitiveLiteral::Int(_) => PrimitiveType::Int,
835 PrimitiveLiteral::Long(_) => PrimitiveType::Long,
836 PrimitiveLiteral::String(_) => PrimitiveType::String,
837 _ => return true,
838 };
839 let bound = |bytes: Option<&[u8]>| {
840 bytes.and_then(|bytes| Datum::try_from_bytes(bytes, ty.clone()).ok())
841 };
842 match (
843 bound(summary.lower_bound.as_deref().map(Vec::as_slice)),
844 bound(summary.upper_bound.as_deref().map(Vec::as_slice)),
845 ) {
846 (Some(lower), Some(upper)) => lower.literal() <= value && value <= upper.literal(),
847 _ => true,
848 }
849 })
850}
851
852async fn load_manifest(
853 binding: &IcebergUpdateBinding,
854 table: &Table,
855 file: &ManifestFile,
856) -> ConnectorResult<(Manifest, Arc<PartitionSpec>)> {
857 ensure!(
858 file.key_metadata.is_none(),
859 "encrypted manifests are unsupported"
860 );
861 let manifest = file.load_manifest(table.file_io()).await?;
862 let spec = table
863 .metadata()
864 .partition_spec_by_id(file.partition_spec_id)
865 .context("manifest references a missing partition spec")?;
866 ensure!(
867 manifest.metadata().partition_spec == **spec,
868 "manifest partition spec differs from table metadata"
869 );
870 spec.partition_type(&binding.schema)?;
872 Ok((manifest, spec.clone()))
873}
874
875fn entry_sequence(entry: &ManifestEntry, max_sequence: i64) -> ConnectorResult<i64> {
877 let file = entry.data_file();
878 ensure!(
879 file.key_metadata().is_none(),
880 "encrypted files are unsupported"
881 );
882 ensure!(
883 file.equality_ids().is_none(),
884 "equality deletes are unsupported"
885 );
886 let sequence = entry
887 .sequence_number()
888 .context("missing file data sequence number")?;
889 ensure!(
890 sequence >= 0 && sequence <= max_sequence,
891 "invalid file sequence number"
892 );
893 Ok(sequence)
894}
895
896async fn load_data_files(
898 binding: &IcebergUpdateBinding,
899 table: &Table,
900 manifest_file: &ManifestFile,
901 max_sequence: i64,
902) -> ConnectorResult<Vec<UpdateFile>> {
903 let (manifest, spec) = load_manifest(binding, table, manifest_file).await?;
904 let partition_type = Type::Struct(spec.partition_type(&binding.schema)?);
905 let mut seen = HashSet::new();
906 manifest
907 .entries()
908 .iter()
909 .filter(|entry| entry.is_alive())
910 .map(|entry| -> ConnectorResult<_> {
911 let sequence = entry_sequence(entry, max_sequence)?;
912 let file = entry.data_file();
913 ensure!(
914 file.content_type() == DataContentType::Data,
915 "non-data file in data manifest"
916 );
917 ensure!(
918 file.file_format() == DataFileFormat::Parquet,
919 "update reader requires Parquet data files"
920 );
921 ensure!(
922 file.record_count() <= i64::MAX as u64,
923 "data record count exceeds position range"
924 );
925 ensure!(
926 seen.insert(file.file_path()),
927 "duplicate live data-file path"
928 );
929 Ok(UpdateFile {
930 path: file.file_path().to_owned(),
931 size: file.file_size_in_bytes(),
932 record_count: file.record_count(),
933 sequence_number: sequence,
934 file_sequence_number: entry.file_sequence_number,
935 first_row_id: file.first_row_id(),
936 partition: Literal::Struct(file.partition().clone())
937 .try_into_json(&partition_type)?,
938 partition_spec: spec.clone(),
939 deletes: vec![],
940 })
941 })
942 .collect()
943}
944
945struct DeleteEntry {
946 delete: FileScanTaskDeleteFile,
947 partition: Struct,
949}
950
951type DeleteIndex = BTreeMap<String, DeleteEntry>;
953
954async fn load_deletes(
956 binding: &IcebergUpdateBinding,
957 table: &Table,
958 manifest_file: &ManifestFile,
959 max_sequence: i64,
960) -> ConnectorResult<Vec<(String, DeleteEntry)>> {
961 let (manifest, _) = load_manifest(binding, table, manifest_file).await?;
962 let mut seen = HashSet::new();
963 manifest
964 .entries()
965 .iter()
966 .filter(|entry| entry.is_alive())
967 .map(|entry| -> ConnectorResult<_> {
968 let sequence = entry_sequence(entry, max_sequence)?;
969 let file = entry.data_file();
970 ensure!(
971 file.content_type() == DataContentType::PositionDeletes,
972 "only position deletes are supported"
973 );
974 let referenced = file
975 .referenced_data_file()
976 .context("position deletes require a referenced data file")?;
977 let delete = FileScanTaskDeleteFile::builder()
978 .with_file_path(file.file_path().to_owned())
979 .with_file_size_in_bytes(file.file_size_in_bytes())
980 .with_file_type(DataContentType::PositionDeletes)
981 .with_partition_spec_id(manifest_file.partition_spec_id)
982 .with_file_format(file.file_format())
983 .with_referenced_data_file(Some(referenced.clone()))
984 .with_record_count(Some(file.record_count()))
985 .with_content_offset(file.content_offset())
986 .with_content_size_in_bytes(file.content_size_in_bytes())
987 .with_sequence_number(sequence)
988 .build();
989 validate_position_delete_descriptor(&delete, &referenced)?;
990 ensure!(
991 seen.insert(referenced.clone()),
992 "multiple live delete artifacts for one data file"
993 );
994 Ok((
995 referenced,
996 DeleteEntry {
997 delete,
998 partition: file.partition().clone(),
999 },
1000 ))
1001 })
1002 .collect()
1003}
1004
1005async fn index_deletes<'a>(
1008 binding: &IcebergUpdateBinding,
1009 table: &Table,
1010 manifests: impl Iterator<Item = &'a ManifestFile>,
1011 max_sequence: i64,
1012) -> ConnectorResult<DeleteIndex> {
1013 let mut index = BTreeMap::new();
1014 for manifest in manifests {
1015 for (path, entry) in load_deletes(binding, table, manifest, max_sequence).await? {
1016 ensure!(
1017 index.insert(path, entry).is_none(),
1018 "multiple live delete artifacts for one data file"
1019 );
1020 }
1021 }
1022 Ok(index)
1023}
1024
1025#[try_stream(boxed, ok = UpdateFile, error = ConnectorError)]
1029async fn stream_files(binding: Arc<IcebergUpdateBinding>, table: Table, snapshot_id: i64) {
1030 let snapshot = binding.snapshot(&table, snapshot_id)?.clone();
1031 let cache = table.object_cache();
1032 let metadata = table.metadata_ref();
1033 let list = cache.get_manifest_list(&snapshot, &metadata).await?;
1034 let sequence = snapshot.sequence_number();
1035 let mut deletes = index_deletes(
1036 &binding,
1037 &table,
1038 of_content(list.entries(), ManifestContentType::Deletes),
1039 sequence,
1040 )
1041 .await?;
1042 let mut seen = HashSet::new();
1043 for manifest in of_content(list.entries(), ManifestContentType::Data) {
1044 for mut file in load_data_files(&binding, &table, manifest, sequence).await? {
1045 ensure!(
1046 seen.insert(file.path.clone()),
1047 "duplicate live data-file path"
1048 );
1049 file.deletes = attached_deletes(&file, deletes.remove(&file.path).as_ref())?;
1050 yield file;
1051 }
1052 }
1053 }
1055
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1060pub struct IcebergUpdateCursor {
1061 pub snapshot_id: i64,
1063 pub parent_snapshot_id: Option<i64>,
1065 pub bootstrap: bool,
1066 pub phase: IcebergUpdatePhase,
1067 pub after_path: Option<String>,
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1071pub struct IcebergUpdatePage {
1072 pub phase_finished: bool,
1073 pub tasks: Vec<IcebergUpdateTask>,
1074}
1075
1076impl IcebergUpdatePlanner {
1077 pub fn start_cursor(
1078 &self,
1079 table: &Table,
1080 bootstrap_complete: bool,
1081 applied_snapshot: Option<i64>,
1082 ) -> ConnectorResult<Option<IcebergUpdateCursor>> {
1083 self.binding.validate(table)?;
1084 let (id, parent, bootstrap) = if !bootstrap_complete {
1085 let Some(id) = self.binding.bootstrap_snapshot_id else {
1086 return Ok(None);
1087 };
1088 self.binding.ensure_ancestor(table, Some(id))?;
1089 (id, None, true)
1090 } else {
1091 let pending = self.binding.pending(table, applied_snapshot)?;
1092 let len = range_len(&pending);
1093 if len == 0 {
1094 return Ok(None);
1095 }
1096 (pending[len - 1].snapshot_id(), applied_snapshot, false)
1097 };
1098 Ok(Some(IcebergUpdateCursor {
1099 snapshot_id: id,
1100 parent_snapshot_id: parent,
1101 bootstrap,
1102 phase: if bootstrap {
1103 IcebergUpdatePhase::Insert
1104 } else {
1105 IcebergUpdatePhase::Delete
1106 },
1107 after_path: None,
1108 }))
1109 }
1110
1111 pub async fn plan_page(
1112 &mut self,
1113 table: &Table,
1114 cursor: &IcebergUpdateCursor,
1115 batch_size: usize,
1116 ) -> ConnectorResult<IcebergUpdatePage> {
1117 ensure!(batch_size > 0, "Iceberg fetch batch size must be positive");
1118 self.binding.validate(table)?;
1119 let matches = if cursor.bootstrap {
1120 self.binding
1121 .ensure_ancestor(table, self.binding.bootstrap_snapshot_id)?;
1122 self.binding.bootstrap_snapshot_id == Some(cursor.snapshot_id)
1123 && cursor.parent_snapshot_id.is_none()
1124 } else {
1125 let pending = self.binding.pending(table, cursor.parent_snapshot_id)?;
1128 pending[..range_len(&pending)]
1129 .iter()
1130 .any(|snapshot| snapshot.snapshot_id() == cursor.snapshot_id)
1131 };
1132 ensure!(
1133 matches,
1134 "update cursor no longer matches its fixed snapshots"
1135 );
1136 ensure!(
1137 !cursor.bootstrap || cursor.phase == IcebergUpdatePhase::Insert,
1138 "invalid bootstrap phase"
1139 );
1140
1141 let mut remaining = match self.enumeration.take() {
1144 Some((previous, tasks)) if previous == *cursor => tasks,
1145 _ => {
1146 let mut tasks =
1147 stream_tasks(self.binding.clone(), table.clone(), cursor.clone()).peekable();
1148 if let Some(path) = &cursor.after_path {
1149 loop {
1150 let task = tasks
1151 .try_next()
1152 .await?
1153 .context("invalid exclusive update cursor")?;
1154 if task.id.data_file_path == *path {
1155 break;
1156 }
1157 }
1158 }
1159 tasks
1160 }
1161 };
1162 let mut tasks = Vec::with_capacity(batch_size);
1163 while tasks.len() < batch_size {
1164 let Some(task) = remaining.try_next().await? else {
1165 break;
1166 };
1167 tasks.push(task);
1168 }
1169 let phase_finished = match Pin::new(&mut remaining).peek().await {
1171 None => true,
1172 Some(Ok(_)) => false,
1173 Some(Err(_)) => {
1174 return Err(remaining.next().await.expect("peeked error").unwrap_err());
1175 }
1176 };
1177 let mut next = cursor.clone();
1178 if let Some(task) = tasks.last() {
1179 next.after_path = Some(task.id.data_file_path.clone());
1180 }
1181 self.enumeration = Some((next, remaining));
1182 Ok(IcebergUpdatePage {
1183 phase_finished,
1184 tasks,
1185 })
1186 }
1187
1188 pub fn unread(&mut self, cursor: IcebergUpdateCursor, tasks: Vec<IcebergUpdateTask>) {
1191 if let Some((_, remaining)) = self.enumeration.take() {
1192 let tasks = stream::iter(tasks.into_iter().map(Ok))
1193 .chain(remaining)
1194 .boxed()
1195 .peekable();
1196 self.enumeration = Some((cursor, tasks));
1197 }
1198 }
1199}
1200
1201#[cfg(test)]
1202#[path = "update_planner_test.rs"]
1203mod tests;