Skip to main content

risingwave_connector/source/iceberg/
update_planner.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Metadata-only planning of Insert and Delete updates. Each plan covers a range of pending
16//! snapshots as one net change from a base snapshot to a target snapshot. Plans do not advance
17//! ingestion progress: the caller must order Delete before Insert, then acknowledge the target.
18//! This is deliberately separate from the legacy append-only planner and List/Fetch protocol.
19
20use 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/// Persist this binding before bootstrap. Never rediscover the schema or bootstrap snapshot on replay.
47#[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    /// Resolve the List's downstream projection without dropping stored hidden key columns.
58    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    /// The main ancestry after `last` up to the current snapshot, oldest first. Validates every
158    /// snapshot on the way, using links rather than snapshot-ID order.
159    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
191/// The number of pending snapshots planned as one range. A range ends before the next
192/// compaction, and a compaction forms a range of its own: it rewrites rows without changing
193/// them, while a net diff across it would retract and re-insert every rewritten row.
194fn 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/// Stable within a source job. `snapshot_id` is the target of the planned range.
225#[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/// Unlike the legacy split, preserves partition constants, spec, sequence numbers and name mapping.
234/// No predicates, virtual columns, or encryption keys are allowed in this read contract.
235#[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    /// Excludes `first_row_id`: after a V2 to V3 upgrade, inherited data manifests are assigned
250    /// row IDs, and the update read path never projects `_row_id`.
251    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    /// The base of the planned range.
305    pub parent_snapshot_id: Option<i64>,
306    binding: Arc<IcebergUpdateBinding>,
307    file: UpdateFile,
308    parent_deletes: Vec<FileScanTaskDeleteFile>,
309    /// Legacy and mixed overwrite snapshots can remove complete data files.
310    #[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    /// Validate the table and snapshot history, then build the scan task and read mode.
341    /// Use persisted file descriptors, not the latest snapshot's file membership.
342    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/// Collected only by planner/reader tests. Production dispatches the lazy task stream in pages.
369#[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    /// Runtime scan state, never checkpointed or shared with Fetch. Recovery reconstructs the
381    /// deterministic stream and skips its dispatched prefix using the durable cursor.
382    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        // Tests compare logical plans independently of manifest storage order.
445        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
476/// Plans one range phase as a deterministic stream; recovery replays it up to `after_path`.
477/// Bootstrap enumerates the whole snapshot. Incremental phases diff the manifest lists of the
478/// range base and target instead of indexing every live file of either snapshot.
479fn 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
497/// Manifests are immutable, so a manifest path present in both manifest lists contributes the
498/// same entries to both snapshots and never needs to be read for the diff itself. The list-level
499/// `first_row_id` of an inherited data manifest may still be assigned after a V2 to V3 upgrade.
500///
501/// `parent` is the range base and `current` the range target. Snapshots strictly inside the
502/// range are not opened: only the live files of the two ends define the net change, so a file
503/// added and removed within the range produces no task.
504///
505/// File-level semantics assume the writer invariants: a data-file path is live at most once per
506/// snapshot and has at most one live delete artifact. Only the manifests read here are checked.
507struct ManifestDiff {
508    current: SnapshotRef,
509    parent_sequence: i64,
510    /// In current manifest-list order.
511    added: Vec<ManifestFile>,
512    /// In parent manifest-list order.
513    removed: Vec<ManifestFile>,
514    /// In current manifest-list order.
515    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(&current, &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    /// A range containing a compaction contains only that snapshot.
571    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    /// Pure compaction emits nothing, but its changed manifests must still be supported.
580    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/// Retained files are live in both snapshots, either moved between data manifests or kept in an
612/// unchanged one. The latter are looked up only when one of their delete artifacts changed.
613/// Removed files retract the rows visible in the parent.
614#[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        // Never open row artifacts or emit maintenance events.
623        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            // Files absent from the removed manifests are new; the Insert phase owns them.
658            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, &current_deletes)?
665                {
666                    yield task;
667                }
668            }
669        }
670    }
671
672    // A delete artifact in a changed delete manifest may reference a retained file kept in an
673    // unchanged data manifest. Deletes in unchanged manifests are identical in both snapshots.
674    let mut changed: BTreeMap<&str, Vec<(i32, &Struct)>> = BTreeMap::new();
675    for (path, entry) in parent_deletes.iter().chain(&current_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    // A removed file's parent delete artifact may stay live in an unchanged manifest.
689    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, &current_deletes)?
727            {
728                yield task;
729            }
730        }
731    }
732    // Remaining candidates reference data files that are not live in both snapshots.
733}
734
735/// New files are live in added data manifests but not in removed ones. Files moved between data
736/// manifests are retained. Same-commit delete artifacts only filter Inserts.
737#[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
810/// Conservative partition-summary pruning: `false` only when the summaries exclude `partition`.
811/// Iceberg applies a position delete only to data files with its spec and partition, so such a
812/// manifest cannot hold a data file that the delete applies to.
813fn 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            // Only types whose `PrimitiveLiteral` order matches the Iceberg order are pruned.
833            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    // Resolve against the bound schema, including historical partition specs.
871    spec.partition_type(&binding.schema)?;
872    Ok((manifest, spec.clone()))
873}
874
875/// `max_sequence` is the sequence number of a snapshot containing the manifest.
876fn 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
896/// Live, validated data files of one data manifest, without delete descriptors.
897async 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    /// Used only to prune data-manifest lookups by partition summary.
948    partition: Struct,
949}
950
951/// Keyed by referenced data-file path.
952type DeleteIndex = BTreeMap<String, DeleteEntry>;
953
954/// Live, validated delete descriptors of one delete manifest.
955async 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
1005/// Rejects multiple live delete artifacts for one data file across `manifests`, which must all
1006/// belong to one snapshot.
1007async 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/// Every live data file of a snapshot, for bootstrap. Delete descriptors are indexed before data
1026/// enumeration, since manifests are not ordered by referenced data path. Only one data manifest
1027/// and one task page are materialized at a time, in addition to the delete index.
1028#[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    // Remaining descriptors reference data files that are no longer live.
1054}
1055
1056/// Last dispatched file within a deterministic range/phase stream, not a sorted path bound.
1057/// Recovery replays metadata up to this marker; normal pages retain the live manifest scan.
1058/// Advancing enumeration does not acknowledge application: List orders or fences phases.
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1060pub struct IcebergUpdateCursor {
1061    /// The range target, fixed when the range starts even if later snapshots are committed.
1062    pub snapshot_id: i64,
1063    /// The range base: the last acknowledged snapshot, or `None` before the first snapshot.
1064    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            // Later commits only extend the pending ancestry, so they never move the range end
1126            // that bounds a fixed target.
1127            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        // Taking the runtime scan makes errors/cancellation failure-atomic: the durable cursor
1142        // is unchanged and a retry reconstructs the stream instead of reusing partial progress.
1143        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        // Keep at most one lookahead task, not a complete snapshot plan.
1170        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    /// Returns an undispatched page from `cursor`, so that the next page from `cursor` yields
1189    /// the same tasks again without rebuilding the scan.
1190    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;