Skip to main content

iceberg/cow_rewrite/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Copy-on-write rewrite primitives.
19//!
20//! This module plans candidate data files, reads their visible rows, applies a
21//! caller-provided batch rewriter, and writes replacement data files. It returns
22//! old and new file sets that can be committed by an overwrite-style transaction
23//! action.
24//!
25//! The primitive does not parse SQL and does not commit metadata by itself.
26//! Rewriters must emit batches compatible with the schema rows were read in
27//! (the planned snapshot's schema) and must preserve each source file's
28//! partition values; this primitive does not repartition rewritten rows.
29//!
30//! Replacement files deliberately mirror their source file's layout instead of
31//! re-optimizing it — a deliberate divergence from Java's RewriteDataFiles and
32//! Spark DML: they are written in the planned snapshot's schema rather than
33//! promoted to the table's current schema, they keep the source file's
34//! partition spec rather than being repartitioned into the table's default
35//! spec, and they stay unsorted since no sort order is applied. An opt-in
36//! promote-to-current-schema rewrite is left for follow-up work.
37//!
38//! The result carries data files only. A commit adapter consuming these file
39//! lists may remove position delete files and deletion vectors only when they
40//! exclusively reference removed data files. Equality delete files must be
41//! retained while they can still apply to other live data files by sequence
42//! number.
43//!
44//! ```rust,no_run
45//! # use std::sync::Arc;
46//! # use arrow_array::RecordBatch;
47//! # use iceberg::cow_rewrite::{CowBatchRewrite, CowBatchRewriter, CowRewriteBuilder};
48//! # use iceberg::table::Table;
49//! # use iceberg::Result;
50//! struct KeepAll;
51//!
52//! impl CowBatchRewriter for KeepAll {
53//!     fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
54//!         Ok(CowBatchRewrite {
55//!             output: Some(batch),
56//!             changed: false,
57//!         })
58//!     }
59//! }
60//!
61//! # async fn example(table: &Table) -> Result<()> {
62//! let result = CowRewriteBuilder::new(table)
63//!     .with_rewriter(Arc::new(KeepAll))
64//!     .rewrite()
65//!     .await?;
66//!
67//! assert!(!result.has_changes());
68//! # Ok(())
69//! # }
70//! ```
71
72mod plan;
73mod rewriter;
74pub(crate) mod writer;
75
76use std::sync::Arc;
77
78use arrow_array::RecordBatch;
79use futures::TryStreamExt;
80pub use plan::CowRewriteFile;
81pub use rewriter::{CowBatchRewrite, CowBatchRewriter};
82
83use crate::expr::Predicate;
84use crate::scan::FileScanTaskStream;
85use crate::spec::{DataFile, PartitionKey};
86use crate::table::Table;
87use crate::{Error, ErrorKind, Result};
88
89/// Counters produced by a copy-on-write rewrite.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct CowRewriteStats {
92    /// Number of candidate files selected by planning.
93    pub candidate_files: usize,
94    /// Number of old files that have replacement output or are fully removed.
95    pub rewritten_files: usize,
96    /// Number of candidate files that did not change after row rewriting.
97    pub unchanged_files: usize,
98    /// Visible input row count read from candidate files.
99    pub input_rows: u64,
100    /// Output row count written to replacement files.
101    ///
102    /// Rows the rewriter emitted for files that turned out unchanged are not
103    /// counted, so this always matches the row counts of `added_data_files`.
104    pub output_rows: u64,
105    /// Number of input batches that changed, including batches the rewriter
106    /// dropped entirely (`output: None`) even if it did not flag them.
107    pub changed_batches: u64,
108}
109
110/// Result of a copy-on-write rewrite operation.
111#[derive(Debug, Default)]
112pub struct CowRewriteResult {
113    /// Old data files that should be removed by the commit action.
114    pub removed_data_files: Vec<DataFile>,
115    /// New data files that should be added by the commit action.
116    pub added_data_files: Vec<DataFile>,
117    /// Candidate files that were read and left unchanged.
118    ///
119    /// Files whose visible rows were all removed by delete files are NOT
120    /// included here: they read as zero rows and are reported in
121    /// `removed_data_files` with no replacement. A commit adapter may remove
122    /// position deletes and deletion vectors that exclusively reference these
123    /// removed files, but must retain equality deletes that can still apply to
124    /// other live files.
125    pub unchanged_data_files: Vec<DataFile>,
126    /// Rewrite counters.
127    pub stats: CowRewriteStats,
128}
129
130impl CowRewriteResult {
131    /// Returns true if the rewrite produced any table changes.
132    pub fn has_changes(&self) -> bool {
133        !self.removed_data_files.is_empty() || !self.added_data_files.is_empty()
134    }
135}
136
137/// Builder for orchestrating copy-on-write data file rewrites.
138pub struct CowRewriteBuilder<'a> {
139    table: &'a Table,
140    predicate: Predicate,
141    snapshot_id: Option<i64>,
142    batch_size: Option<usize>,
143    case_sensitive: bool,
144    rewriter: Option<Arc<dyn CowBatchRewriter>>,
145}
146
147impl<'a> CowRewriteBuilder<'a> {
148    /// Creates a copy-on-write rewrite builder for `table`.
149    pub fn new(table: &'a Table) -> Self {
150        Self {
151            table,
152            predicate: Predicate::AlwaysTrue,
153            snapshot_id: None,
154            batch_size: None,
155            case_sensitive: true,
156            rewriter: None,
157        }
158    }
159
160    /// Sets the row predicate used to plan candidate files.
161    pub fn with_predicate(mut self, predicate: Predicate) -> Self {
162        self.predicate = predicate;
163        self
164    }
165
166    /// Sets the snapshot id used to plan candidate files.
167    pub fn with_snapshot_id(mut self, snapshot_id: i64) -> Self {
168        self.snapshot_id = Some(snapshot_id);
169        self
170    }
171
172    /// Sets the Arrow reader batch size.
173    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
174        self.batch_size = Some(batch_size);
175        self
176    }
177
178    /// Sets the case sensitivity used to bind the planning predicate.
179    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
180        self.case_sensitive = case_sensitive;
181        self
182    }
183
184    /// Sets the record batch rewriter.
185    pub fn with_rewriter(mut self, rewriter: Arc<dyn CowBatchRewriter>) -> Self {
186        self.rewriter = Some(rewriter);
187        self
188    }
189
190    /// Plans, reads, rewrites, and writes replacement data files.
191    ///
192    /// # Memory usage
193    ///
194    /// Batches read before a file's first changed batch are buffered so that
195    /// no replacement file is emitted for a source file that turns out
196    /// unchanged. A file that never changes — or whose first change sits at
197    /// its very end — therefore buffers its entire decoded contents in
198    /// memory. Files are processed sequentially, so peak usage is one decoded
199    /// source file at a time, but that can still be several GB for a
200    /// compaction-sized file. A size-capped fallback that starts writing the
201    /// replacement once the buffer crosses a threshold is left for follow-up
202    /// work.
203    pub async fn rewrite(self) -> Result<CowRewriteResult> {
204        let rewriter = self.rewriter.ok_or_else(|| {
205            Error::new(
206                ErrorKind::PreconditionFailed,
207                "COW rewrite requires a batch rewriter",
208            )
209        })?;
210        let files = plan::plan_cow_rewrite_files(
211            self.table,
212            Some(self.predicate),
213            self.snapshot_id,
214            self.case_sensitive,
215        )
216        .await?;
217
218        let mut result = CowRewriteResult {
219            stats: CowRewriteStats {
220                candidate_files: files.len(),
221                ..CowRewriteStats::default()
222            },
223            ..CowRewriteResult::default()
224        };
225
226        for file in files {
227            let CowRewriteFile {
228                old_data_file,
229                scan_task,
230            } = file;
231            // Schema the rows are read in (the planned snapshot's schema). The
232            // replacement files must be written with this schema so that batches
233            // remain compatible when the table's current schema has evolved past
234            // the snapshot the source files belong to. This preserves the older
235            // schema in replacement files rather than promoting them to the
236            // table's current schema during this rewrite.
237            let write_schema = scan_task.schema_ref();
238            let has_delete_files = !scan_task.deletes().is_empty();
239
240            // Batches produced before the first changed batch. They are buffered
241            // rather than written immediately because the primitive must not
242            // emit a replacement file for a source file that turns out to be
243            // unchanged. Once a changed batch is observed the buffered prefix is
244            // flushed to the writer and all subsequent batches stream straight
245            // through.
246            //
247            // Worst-case footprint: for a file that never changes (or whose
248            // first change sits at its very end) the prefix holds the entire
249            // decoded source file in memory. Files are processed sequentially,
250            // so peak usage is one file at a time, but that can still be several
251            // GB for a compaction-sized file. A size-capped fallback that starts
252            // writing the replacement once the buffer crosses a threshold is
253            // left for follow-up work.
254            let mut prefix: Vec<RecordBatch> = Vec::new();
255            let mut file_changed = false;
256            let mut file_input_rows = 0_u64;
257            let mut file_output_rows = 0_u64;
258            let mut writer: Option<Box<dyn crate::writer::IcebergWriter>> = None;
259            // Debug-only tripwire for the "preserve partition values"
260            // contract; see `DebugPartitionGuard`.
261            #[cfg(debug_assertions)]
262            let partition_guard =
263                DebugPartitionGuard::new(self.table, &old_data_file, &write_schema)?;
264
265            // Planning already cleared the row predicate (see
266            // `ManifestEntryContext::into_cow_rewrite_file`), so this task
267            // reads every row of the source file.
268            let tasks = Box::pin(futures::stream::iter(vec![Ok(scan_task)])) as FileScanTaskStream;
269
270            // Each candidate file gets its own reader so the per-file prefix
271            // and lazy-writer semantics stay intact; the delete-file cache is
272            // therefore also per file, and equality deletes shared by several
273            // candidates are fetched once per file.
274            let mut reader_builder = self.table.reader_builder();
275            if let Some(batch_size) = self.batch_size {
276                reader_builder = reader_builder.with_batch_size(batch_size);
277            }
278
279            let mut batches = reader_builder.build().read(tasks)?.stream();
280            while let Some(batch) = batches.try_next().await? {
281                result.stats.input_rows += batch.num_rows() as u64;
282                file_input_rows += batch.num_rows() as u64;
283
284                let rewrite = rewriter.rewrite_batch(batch)?;
285                // `output: None` means the batch is fully removed, which is
286                // itself a change. Derive the effective flag instead of
287                // trusting every rewriter to keep `changed` consistent with
288                // `output` — otherwise a `{changed: false, output: None}`
289                // batch would silently drop its rows while leaving the file
290                // marked unchanged.
291                let changed = rewrite.changed || rewrite.output.is_none();
292                if changed {
293                    file_changed = true;
294                    result.stats.changed_batches += 1;
295                }
296
297                // The guard must see every emitted batch, including ones that
298                // are only buffered into the prefix before the file flips to
299                // changed.
300                #[cfg(debug_assertions)]
301                if let (Some(guard), Some(output)) = (&partition_guard, &rewrite.output) {
302                    guard.check(output)?;
303                }
304
305                // A dropped batch can be the first change. Flush any kept
306                // prefix even when this batch has no output; defer opening the
307                // writer only if there are no rows to preserve yet.
308                if file_changed
309                    && writer.is_none()
310                    && (!prefix.is_empty() || rewrite.output.is_some())
311                {
312                    let partition_key =
313                        source_partition_key(self.table, &old_data_file, &write_schema)?;
314                    writer = Some(
315                        writer::build_replacement_writer(
316                            self.table,
317                            write_schema.clone(),
318                            Some(partition_key),
319                        )
320                        .await?,
321                    );
322                }
323
324                if let Some(writer) = writer.as_mut() {
325                    for prefix_batch in prefix.drain(..) {
326                        writer.write(prefix_batch).await?;
327                    }
328                    if let Some(output) = rewrite.output {
329                        file_output_rows += output.num_rows() as u64;
330                        writer.write(output).await?;
331                    }
332                } else if let Some(output) = rewrite.output {
333                    file_output_rows += output.num_rows() as u64;
334                    prefix.push(output);
335                }
336            }
337
338            // A candidate whose visible rows were all removed by its delete
339            // files reads as zero rows and the loop above never runs. Treat it
340            // the same as a rewriter that dropped every batch — changed with
341            // no replacement — so the file and its delete files can be
342            // compacted away instead of being pinned in the table forever.
343            let fully_removed_by_deletes =
344                !file_changed && file_input_rows == 0 && has_delete_files;
345
346            if file_changed || fully_removed_by_deletes {
347                result.stats.rewritten_files += 1;
348                result.stats.output_rows += file_output_rows;
349                result.removed_data_files.push(old_data_file);
350
351                if let Some(mut writer) = writer {
352                    let added_data_files = writer.close().await?;
353                    result.added_data_files.extend(added_data_files);
354                }
355                // If `writer` is `None`, no visible rows remain after delete
356                // files and batch rewriting, so no replacement is written.
357            } else {
358                result.stats.unchanged_files += 1;
359                result.unchanged_data_files.push(old_data_file);
360                // `prefix` is dropped here; no replacement file was written.
361            }
362        }
363
364        Ok(result)
365    }
366}
367
368fn source_partition_key(
369    table: &Table,
370    data_file: &DataFile,
371    schema: &crate::spec::SchemaRef,
372) -> Result<PartitionKey> {
373    let spec = table
374        .metadata()
375        .partition_spec_by_id(data_file.partition_spec_id)
376        .ok_or_else(|| {
377            Error::new(
378                ErrorKind::DataInvalid,
379                format!(
380                    "Missing partition spec {} for COW rewrite source file",
381                    data_file.partition_spec_id
382                ),
383            )
384        })?
385        .as_ref()
386        .clone();
387    // `PartitionKey::new` does not bind the spec to the schema, so validate the
388    // binding here: a spec that is incompatible with the planned snapshot
389    // schema should fail with a clear error instead of producing a bad
390    // partition path when the writer later calls `PartitionKey::to_path`.
391    spec.partition_type(schema).map_err(|err| {
392        Error::new(
393            ErrorKind::DataInvalid,
394            format!(
395                "Cannot bind partition spec {} to the planned snapshot schema for COW rewrite",
396                data_file.partition_spec_id
397            ),
398        )
399        .with_source(err)
400    })?;
401
402    Ok(PartitionKey::new(
403        spec,
404        schema.clone(),
405        data_file.partition().clone(),
406    ))
407}
408
409/// Debug-only tripwire for the rewriter contract that output batches preserve
410/// the source file's partition values: replacement files record the source
411/// partition verbatim, so a rewriter that mutates a partition-source column
412/// would silently write rows under a partition they do not belong to. Compiled
413/// out in release builds, where per-row partition calculation would be too
414/// expensive for the rewrite hot path.
415#[cfg(debug_assertions)]
416struct DebugPartitionGuard {
417    calculator: crate::arrow::PartitionValueCalculator,
418    partition_type: crate::spec::StructType,
419    expected: crate::spec::Struct,
420}
421
422#[cfg(debug_assertions)]
423impl DebugPartitionGuard {
424    /// Builds a guard for one candidate file, or `None` when the source
425    /// file's partition spec is unpartitioned. Mirrors the spec lookup and
426    /// schema binding of [`source_partition_key`].
427    fn new(
428        table: &Table,
429        data_file: &DataFile,
430        schema: &crate::spec::SchemaRef,
431    ) -> Result<Option<Self>> {
432        let spec = table
433            .metadata()
434            .partition_spec_by_id(data_file.partition_spec_id)
435            .ok_or_else(|| {
436                Error::new(
437                    ErrorKind::DataInvalid,
438                    format!(
439                        "Missing partition spec {} for COW rewrite source file",
440                        data_file.partition_spec_id
441                    ),
442                )
443            })?;
444        if spec.is_unpartitioned() {
445            return Ok(None);
446        }
447
448        Ok(Some(Self {
449            calculator: crate::arrow::PartitionValueCalculator::try_new(spec, schema)?,
450            partition_type: spec.partition_type(schema)?,
451            expected: data_file.partition().clone(),
452        }))
453    }
454
455    /// Asserts that every row in `output` computes to the source file's
456    /// partition under the source spec.
457    fn check(&self, output: &RecordBatch) -> Result<()> {
458        let partition_array = self.calculator.calculate(output)?;
459        let values = crate::arrow::arrow_struct_to_literal(&partition_array, &self.partition_type)?;
460        for value in values {
461            debug_assert_eq!(
462                value,
463                Some(crate::spec::Literal::Struct(self.expected.clone())),
464                "COW rewriter must preserve the source file's partition values: \
465                 replacement files record the source partition verbatim, so \
466                 mutating a partition-source column writes rows under a \
467                 partition they do not belong to"
468            );
469        }
470        Ok(())
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use std::collections::{HashMap, HashSet};
477    use std::sync::Arc;
478
479    use arrow_array::{Array, ArrayRef, BooleanArray, Int32Array, RecordBatch};
480    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
481    use futures::TryStreamExt;
482    use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
483    use tempfile::TempDir;
484
485    use crate::cow_rewrite::{CowBatchRewrite, CowBatchRewriter, CowRewriteBuilder};
486    use crate::io::LocalFsStorageFactory;
487    use crate::memory::{MEMORY_CATALOG_WAREHOUSE, MemoryCatalogBuilder};
488    use crate::scan::{FileScanTask, FileScanTaskStream};
489    use crate::spec::{
490        DataFile, Literal, NestedField, PrimitiveType, Schema, Struct, TableProperties, Transform,
491        Type,
492    };
493    use crate::table::Table;
494    use crate::test_utils::scan::TableTestFixture;
495    use crate::transaction::{AddColumn, ApplyTransactionAction, Transaction};
496    use crate::{Catalog, CatalogBuilder, Error, ErrorKind, NamespaceIdent, Result, TableCreation};
497
498    struct KeepAll;
499
500    impl CowBatchRewriter for KeepAll {
501        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
502            Ok(CowBatchRewrite {
503                output: Some(batch),
504                changed: false,
505            })
506        }
507    }
508
509    struct DeleteEvenIds;
510
511    impl CowBatchRewriter for DeleteEvenIds {
512        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
513            let ids = batch
514                .column_by_name("id")
515                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id column"))?
516                .as_any()
517                .downcast_ref::<Int32Array>()
518                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be Int32"))?;
519
520            let keep =
521                BooleanArray::from_iter((0..ids.len()).map(|row| Some(ids.value(row) % 2 != 0)));
522            let filtered = arrow_select::filter::filter_record_batch(&batch, &keep)
523                .map_err(|err| Error::new(ErrorKind::Unexpected, err.to_string()))?;
524
525            Ok(CowBatchRewrite {
526                changed: filtered.num_rows() != batch.num_rows(),
527                output: (filtered.num_rows() > 0).then_some(filtered),
528            })
529        }
530    }
531
532    struct IncrementValueForEvenIds;
533
534    impl CowBatchRewriter for IncrementValueForEvenIds {
535        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
536            let ids = batch
537                .column_by_name("id")
538                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id column"))?
539                .as_any()
540                .downcast_ref::<Int32Array>()
541                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be Int32"))?;
542            let values = batch
543                .column_by_name("value")
544                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing value column"))?
545                .as_any()
546                .downcast_ref::<Int32Array>()
547                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must be Int32"))?;
548
549            let mut changed = false;
550            let updated_values = Int32Array::from_iter((0..values.len()).map(|row| {
551                let value = values.value(row);
552                if ids.value(row) % 2 == 0 {
553                    changed = true;
554                    Some(value + 10)
555                } else {
556                    Some(value)
557                }
558            }));
559            let output = RecordBatch::try_new(batch.schema(), vec![
560                batch.column(0).clone(),
561                Arc::new(updated_values),
562            ])
563            .map_err(|err| Error::new(ErrorKind::Unexpected, err.to_string()))?;
564
565            Ok(CowBatchRewrite {
566                output: Some(output),
567                changed,
568            })
569        }
570    }
571
572    struct CowRewriteFixture {
573        _temp_dir: TempDir,
574        table: Table,
575    }
576
577    async fn test_table_with_ids(ids: Vec<i32>) -> Result<CowRewriteFixture> {
578        let temp_dir = TempDir::new().unwrap();
579        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
580        let catalog = MemoryCatalogBuilder::default()
581            .with_storage_factory(Arc::new(LocalFsStorageFactory))
582            .load(
583                "memory",
584                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
585            )
586            .await?;
587        let namespace = NamespaceIdent::new("ns".to_string());
588        catalog.create_namespace(&namespace, HashMap::new()).await?;
589
590        let schema = Schema::builder()
591            .with_fields(vec![
592                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
593            ])
594            .build()?;
595        let table = catalog
596            .create_table(
597                &namespace,
598                TableCreation::builder()
599                    .name("cow_rewrite_fixture".to_string())
600                    .schema(schema)
601                    .build(),
602            )
603            .await?;
604
605        let arrow_schema = Arc::new(ArrowSchema::new(vec![
606            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
607                PARQUET_FIELD_ID_META_KEY.to_string(),
608                "1".to_string(),
609            )])),
610        ]));
611        let batch = RecordBatch::try_new(arrow_schema, vec![
612            Arc::new(Int32Array::from(ids)) as ArrayRef
613        ])?;
614        let data_files = super::writer::write_replacement_batches(
615            &table,
616            table.metadata().current_schema().clone(),
617            None,
618            futures::stream::iter(vec![Ok(batch)]),
619        )
620        .await?;
621
622        let tx = Transaction::new(&table);
623        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
624        let table = tx.commit(&catalog).await?;
625
626        Ok(CowRewriteFixture {
627            _temp_dir: temp_dir,
628            table,
629        })
630    }
631
632    async fn test_table_with_id_batches(batches: Vec<Vec<i32>>) -> Result<CowRewriteFixture> {
633        let temp_dir = TempDir::new().unwrap();
634        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
635        let catalog = MemoryCatalogBuilder::default()
636            .with_storage_factory(Arc::new(LocalFsStorageFactory))
637            .load(
638                "memory",
639                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
640            )
641            .await?;
642        let namespace = NamespaceIdent::new("ns".to_string());
643        catalog.create_namespace(&namespace, HashMap::new()).await?;
644
645        let schema = Schema::builder()
646            .with_fields(vec![
647                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
648            ])
649            .build()?;
650        let table = catalog
651            .create_table(
652                &namespace,
653                TableCreation::builder()
654                    .name("cow_rewrite_fixture".to_string())
655                    .schema(schema)
656                    .properties(HashMap::from([(
657                        TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
658                        "1".to_string(),
659                    )]))
660                    .build(),
661            )
662            .await?;
663
664        let arrow_schema = Arc::new(ArrowSchema::new(vec![
665            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
666                PARQUET_FIELD_ID_META_KEY.to_string(),
667                "1".to_string(),
668            )])),
669        ]));
670        let input = batches.into_iter().map(|ids| {
671            Ok(RecordBatch::try_new(arrow_schema.clone(), vec![
672                Arc::new(Int32Array::from(ids)) as ArrayRef,
673            ])?)
674        });
675        let data_files = super::writer::write_replacement_batches(
676            &table,
677            table.metadata().current_schema().clone(),
678            None,
679            futures::stream::iter(input),
680        )
681        .await?;
682
683        let tx = Transaction::new(&table);
684        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
685        let table = tx.commit(&catalog).await?;
686
687        Ok(CowRewriteFixture {
688            _temp_dir: temp_dir,
689            table,
690        })
691    }
692
693    async fn test_table_with_id_value_rows(rows: Vec<(i32, i32)>) -> Result<CowRewriteFixture> {
694        let temp_dir = TempDir::new().unwrap();
695        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
696        let catalog = MemoryCatalogBuilder::default()
697            .with_storage_factory(Arc::new(LocalFsStorageFactory))
698            .load(
699                "memory",
700                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
701            )
702            .await?;
703        let namespace = NamespaceIdent::new("ns".to_string());
704        catalog.create_namespace(&namespace, HashMap::new()).await?;
705
706        let schema = Schema::builder()
707            .with_fields(vec![
708                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
709                NestedField::required(2, "value", Type::Primitive(PrimitiveType::Int)).into(),
710            ])
711            .build()?;
712        let table = catalog
713            .create_table(
714                &namespace,
715                TableCreation::builder()
716                    .name("cow_rewrite_fixture".to_string())
717                    .schema(schema)
718                    .build(),
719            )
720            .await?;
721
722        let arrow_schema = Arc::new(ArrowSchema::new(vec![
723            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
724                PARQUET_FIELD_ID_META_KEY.to_string(),
725                "1".to_string(),
726            )])),
727            Field::new("value", DataType::Int32, false).with_metadata(HashMap::from([(
728                PARQUET_FIELD_ID_META_KEY.to_string(),
729                "2".to_string(),
730            )])),
731        ]));
732        let ids = rows.iter().map(|(id, _)| *id).collect::<Vec<_>>();
733        let values = rows.iter().map(|(_, value)| *value).collect::<Vec<_>>();
734        let batch = RecordBatch::try_new(arrow_schema, vec![
735            Arc::new(Int32Array::from(ids)) as ArrayRef,
736            Arc::new(Int32Array::from(values)) as ArrayRef,
737        ])?;
738        let data_files = super::writer::write_replacement_batches(
739            &table,
740            table.metadata().current_schema().clone(),
741            None,
742            futures::stream::iter(vec![Ok(batch)]),
743        )
744        .await?;
745
746        let tx = Transaction::new(&table);
747        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
748        let table = tx.commit(&catalog).await?;
749
750        Ok(CowRewriteFixture {
751            _temp_dir: temp_dir,
752            table,
753        })
754    }
755
756    async fn read_ids(table: &Table, files: &[DataFile]) -> Result<Vec<i32>> {
757        let schema = table.metadata().current_schema().clone();
758        let project_field_ids = schema
759            .as_struct()
760            .fields()
761            .iter()
762            .map(|field| field.id)
763            .collect::<Vec<_>>();
764        let tasks = files
765            .iter()
766            .map(|data_file| {
767                FileScanTask::builder()
768                    .with_file_size_in_bytes(data_file.file_size_in_bytes())
769                    .with_start(0)
770                    .with_length(data_file.file_size_in_bytes())
771                    .with_record_count(Some(data_file.record_count()))
772                    .with_data_file_path(data_file.file_path().to_string())
773                    .with_data_file_format(data_file.file_format())
774                    .with_schema(schema.clone())
775                    .with_project_field_ids(project_field_ids.clone())
776                    .with_case_sensitive(true)
777                    .build()
778            })
779            .collect::<Vec<_>>();
780        let task_stream = Box::pin(futures::stream::iter(tasks)) as FileScanTaskStream;
781        let batches = table
782            .reader_builder()
783            .build()
784            .read(task_stream)?
785            .stream()
786            .try_collect::<Vec<_>>()
787            .await?;
788
789        let mut ids = Vec::new();
790        for batch in batches {
791            let column = batch
792                .column_by_name("id")
793                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id column"))?
794                .as_any()
795                .downcast_ref::<Int32Array>()
796                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be Int32"))?;
797            ids.extend((0..column.len()).map(|row| column.value(row)));
798        }
799        ids.sort_unstable();
800        Ok(ids)
801    }
802
803    async fn read_ids_and_values(table: &Table, files: &[DataFile]) -> Result<Vec<(i32, i32)>> {
804        let schema = table.metadata().current_schema().clone();
805        let project_field_ids = schema
806            .as_struct()
807            .fields()
808            .iter()
809            .map(|field| field.id)
810            .collect::<Vec<_>>();
811        let tasks = files
812            .iter()
813            .map(|data_file| {
814                FileScanTask::builder()
815                    .with_file_size_in_bytes(data_file.file_size_in_bytes())
816                    .with_start(0)
817                    .with_length(data_file.file_size_in_bytes())
818                    .with_record_count(Some(data_file.record_count()))
819                    .with_data_file_path(data_file.file_path().to_string())
820                    .with_data_file_format(data_file.file_format())
821                    .with_schema(schema.clone())
822                    .with_project_field_ids(project_field_ids.clone())
823                    .with_case_sensitive(true)
824                    .build()
825            })
826            .collect::<Vec<_>>();
827        let task_stream = Box::pin(futures::stream::iter(tasks)) as FileScanTaskStream;
828        let batches = table
829            .reader_builder()
830            .build()
831            .read(task_stream)?
832            .stream()
833            .try_collect::<Vec<_>>()
834            .await?;
835
836        let mut rows = Vec::new();
837        for batch in batches {
838            let ids = batch
839                .column_by_name("id")
840                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id column"))?
841                .as_any()
842                .downcast_ref::<Int32Array>()
843                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be Int32"))?;
844            let values = batch
845                .column_by_name("value")
846                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing value column"))?
847                .as_any()
848                .downcast_ref::<Int32Array>()
849                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must be Int32"))?;
850            rows.extend((0..ids.len()).map(|row| (ids.value(row), values.value(row))));
851        }
852        rows.sort_unstable_by_key(|(id, _)| *id);
853        Ok(rows)
854    }
855
856    #[tokio::test]
857    async fn cow_rewrite_keep_all_produces_no_changes() -> Result<()> {
858        let fixture = test_table_with_ids(vec![1, 2, 3]).await?;
859
860        let result = CowRewriteBuilder::new(&fixture.table)
861            .with_predicate(crate::expr::Predicate::AlwaysTrue)
862            .with_rewriter(Arc::new(KeepAll))
863            .rewrite()
864            .await?;
865
866        assert!(!result.has_changes());
867        assert_eq!(result.removed_data_files.len(), 0);
868        assert_eq!(result.added_data_files.len(), 0);
869        assert_eq!(result.unchanged_data_files.len(), 1);
870        assert_eq!(result.stats.candidate_files, 1);
871        assert_eq!(result.stats.unchanged_files, 1);
872        assert_eq!(result.stats.input_rows, 3);
873        // Nothing was written, so rows emitted for the unchanged file are not
874        // counted as output.
875        assert_eq!(result.stats.output_rows, 0);
876
877        Ok(())
878    }
879
880    #[tokio::test]
881    async fn cow_rewrite_requires_rewriter() -> Result<()> {
882        let fixture = test_table_with_ids(vec![1]).await?;
883
884        let err = CowRewriteBuilder::new(&fixture.table)
885            .rewrite()
886            .await
887            .expect_err("missing rewriter should fail");
888
889        assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
890
891        Ok(())
892    }
893
894    #[tokio::test]
895    async fn cow_rewrite_delete_rows_removes_old_file_and_adds_replacement() -> Result<()> {
896        let fixture = test_table_with_ids(vec![1, 2, 3, 4]).await?;
897
898        let result = CowRewriteBuilder::new(&fixture.table)
899            .with_predicate(crate::expr::Predicate::AlwaysTrue)
900            .with_rewriter(Arc::new(DeleteEvenIds))
901            .rewrite()
902            .await?;
903
904        assert!(result.has_changes());
905        assert_eq!(result.removed_data_files.len(), 1);
906        assert_eq!(result.added_data_files.len(), 1);
907        assert_eq!(result.stats.input_rows, 4);
908        assert_eq!(result.stats.output_rows, 2);
909
910        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
911        assert_eq!(ids, vec![1, 3]);
912
913        Ok(())
914    }
915
916    #[tokio::test]
917    async fn cow_rewrite_update_rows_rewrites_file_with_updated_values() -> Result<()> {
918        let fixture =
919            test_table_with_id_value_rows(vec![(1, 10), (2, 20), (3, 30), (4, 40)]).await?;
920
921        let result = CowRewriteBuilder::new(&fixture.table)
922            .with_predicate(crate::expr::Predicate::AlwaysTrue)
923            .with_rewriter(Arc::new(IncrementValueForEvenIds))
924            .rewrite()
925            .await?;
926
927        assert_eq!(result.removed_data_files.len(), 1);
928        assert_eq!(result.added_data_files.len(), 1);
929        assert_eq!(result.stats.input_rows, 4);
930        assert_eq!(result.stats.output_rows, 4);
931
932        let rows = read_ids_and_values(&fixture.table, &result.added_data_files).await?;
933        assert_eq!(rows, vec![(1, 10), (2, 30), (3, 30), (4, 50)]);
934
935        Ok(())
936    }
937
938    #[tokio::test]
939    async fn cow_rewrite_full_file_delete_removes_old_file_without_replacement() -> Result<()> {
940        let fixture = test_table_with_ids(vec![2, 4]).await?;
941
942        let result = CowRewriteBuilder::new(&fixture.table)
943            .with_predicate(crate::expr::Predicate::AlwaysTrue)
944            .with_rewriter(Arc::new(DeleteEvenIds))
945            .rewrite()
946            .await?;
947
948        assert!(result.has_changes());
949        assert_eq!(result.removed_data_files.len(), 1);
950        assert_eq!(result.added_data_files.len(), 0);
951        assert_eq!(result.stats.input_rows, 2);
952        assert_eq!(result.stats.output_rows, 0);
953
954        Ok(())
955    }
956
957    #[tokio::test]
958    async fn cow_rewrite_removes_file_fully_covered_by_position_deletes() -> Result<()> {
959        let mut fixture = TableTestFixture::new();
960        let positions = (0..300).collect::<Vec<i64>>();
961        fixture.setup_multi_row_group_manifest(&positions).await;
962
963        let result = CowRewriteBuilder::new(&fixture.table)
964            .with_predicate(crate::expr::Predicate::AlwaysTrue)
965            .with_rewriter(Arc::new(KeepAll))
966            .rewrite()
967            .await?;
968
969        assert_eq!(result.stats.candidate_files, 1);
970        assert_eq!(result.stats.rewritten_files, 1);
971        assert_eq!(result.stats.input_rows, 0);
972        assert_eq!(result.stats.output_rows, 0);
973        assert_eq!(result.removed_data_files.len(), 1);
974        assert!(result.added_data_files.is_empty());
975        assert!(result.unchanged_data_files.is_empty());
976
977        Ok(())
978    }
979
980    #[tokio::test]
981    async fn cow_rewrite_delete_no_matching_rows_keeps_old_file() -> Result<()> {
982        let fixture = test_table_with_ids(vec![1, 3]).await?;
983
984        let result = CowRewriteBuilder::new(&fixture.table)
985            .with_predicate(crate::expr::Predicate::AlwaysTrue)
986            .with_rewriter(Arc::new(DeleteEvenIds))
987            .rewrite()
988            .await?;
989
990        assert!(!result.has_changes());
991        assert_eq!(result.removed_data_files.len(), 0);
992        assert_eq!(result.added_data_files.len(), 0);
993        assert_eq!(result.unchanged_data_files.len(), 1);
994        assert_eq!(result.stats.input_rows, 2);
995        assert_eq!(result.stats.output_rows, 0);
996
997        Ok(())
998    }
999
1000    /// Drops every batch while reporting `changed: false`, violating the
1001    /// documented contract. The orchestrator must derive the change from
1002    /// `output: None` itself, otherwise the file would be kept as "unchanged"
1003    /// while its rows were meant to be dropped.
1004    struct SilentFullDrop;
1005
1006    impl CowBatchRewriter for SilentFullDrop {
1007        fn rewrite_batch(&self, _batch: RecordBatch) -> Result<CowBatchRewrite> {
1008            Ok(CowBatchRewrite {
1009                output: None,
1010                changed: false,
1011            })
1012        }
1013    }
1014
1015    #[tokio::test]
1016    async fn cow_rewrite_none_output_implies_change() -> Result<()> {
1017        let fixture = test_table_with_ids(vec![1, 2]).await?;
1018
1019        let result = CowRewriteBuilder::new(&fixture.table)
1020            .with_predicate(crate::expr::Predicate::AlwaysTrue)
1021            .with_rewriter(Arc::new(SilentFullDrop))
1022            .rewrite()
1023            .await?;
1024
1025        assert!(result.has_changes());
1026        assert_eq!(result.removed_data_files.len(), 1);
1027        assert_eq!(result.added_data_files.len(), 0);
1028        assert!(result.unchanged_data_files.is_empty());
1029        assert_eq!(result.stats.rewritten_files, 1);
1030        assert_eq!(result.stats.input_rows, 2);
1031        assert_eq!(result.stats.output_rows, 0);
1032        assert_eq!(result.stats.changed_batches, 1);
1033
1034        Ok(())
1035    }
1036
1037    /// Returns `output: None` with `changed: false` for the first batch, then
1038    /// flags the second batch as changed. The replacement must contain exactly
1039    /// the second batch's rows — the silently dropped first batch must not
1040    /// resurrect, and the original file must be removed.
1041    struct DropFirstBatchSilently {
1042        batches_seen: std::sync::atomic::AtomicUsize,
1043    }
1044
1045    impl CowBatchRewriter for DropFirstBatchSilently {
1046        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
1047            let seen = self
1048                .batches_seen
1049                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1050            if seen == 0 {
1051                Ok(CowBatchRewrite {
1052                    output: None,
1053                    changed: false,
1054                })
1055            } else {
1056                Ok(CowBatchRewrite {
1057                    output: Some(batch),
1058                    changed: true,
1059                })
1060            }
1061        }
1062    }
1063
1064    #[tokio::test]
1065    async fn cow_rewrite_silent_drop_before_change_loses_no_rows() -> Result<()> {
1066        let fixture = test_table_with_ids(vec![1, 2, 3, 4]).await?;
1067
1068        let result = CowRewriteBuilder::new(&fixture.table)
1069            .with_predicate(crate::expr::Predicate::AlwaysTrue)
1070            .with_batch_size(2)
1071            .with_rewriter(Arc::new(DropFirstBatchSilently {
1072                batches_seen: std::sync::atomic::AtomicUsize::new(0),
1073            }))
1074            .rewrite()
1075            .await?;
1076
1077        assert!(result.has_changes());
1078        assert_eq!(result.removed_data_files.len(), 1);
1079        assert_eq!(result.added_data_files.len(), 1);
1080        assert_eq!(result.stats.input_rows, 4);
1081        assert_eq!(result.stats.output_rows, 2);
1082
1083        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
1084        assert_eq!(ids, vec![3, 4]);
1085
1086        Ok(())
1087    }
1088
1089    #[tokio::test]
1090    async fn cow_rewrite_kept_prefix_survives_later_full_batch_delete() -> Result<()> {
1091        let fixture = test_table_with_ids(vec![1, 3, 2, 4]).await?;
1092
1093        let result = CowRewriteBuilder::new(&fixture.table)
1094            .with_predicate(crate::expr::Predicate::AlwaysTrue)
1095            .with_batch_size(2)
1096            .with_rewriter(Arc::new(DeleteEvenIds))
1097            .rewrite()
1098            .await?;
1099
1100        assert_eq!(result.removed_data_files.len(), 1);
1101        assert_eq!(result.stats.input_rows, 4);
1102        assert_eq!(result.stats.output_rows, 2);
1103        assert_eq!(result.added_data_files.len(), 1);
1104        assert!(result.unchanged_data_files.is_empty());
1105        assert_eq!(
1106            read_ids(&fixture.table, &result.added_data_files).await?,
1107            vec![1, 3]
1108        );
1109
1110        Ok(())
1111    }
1112
1113    #[tokio::test]
1114    async fn cow_rewrite_uses_unique_replacement_paths_for_multiple_source_files() -> Result<()> {
1115        let fixture = test_table_with_id_batches(vec![vec![1, 2], vec![3, 4]]).await?;
1116
1117        let result = CowRewriteBuilder::new(&fixture.table)
1118            .with_predicate(crate::expr::Predicate::AlwaysTrue)
1119            .with_rewriter(Arc::new(DeleteEvenIds))
1120            .rewrite()
1121            .await?;
1122
1123        assert_eq!(result.stats.candidate_files, 2);
1124        assert_eq!(result.removed_data_files.len(), 2);
1125        assert_eq!(result.added_data_files.len(), 2);
1126
1127        let added_paths = result
1128            .added_data_files
1129            .iter()
1130            .map(|file| file.file_path().to_string())
1131            .collect::<HashSet<_>>();
1132        let removed_paths = result
1133            .removed_data_files
1134            .iter()
1135            .map(|file| file.file_path().to_string())
1136            .collect::<HashSet<_>>();
1137
1138        assert_eq!(added_paths.len(), result.added_data_files.len());
1139        assert!(added_paths.is_disjoint(&removed_paths));
1140
1141        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
1142        assert_eq!(ids, vec![1, 3]);
1143
1144        Ok(())
1145    }
1146
1147    #[test]
1148    fn cow_batch_rewriter_is_object_safe() {
1149        let _rewriter: Arc<dyn CowBatchRewriter> = Arc::new(KeepAll);
1150    }
1151
1152    #[test]
1153    fn cow_rewrite_result_reports_no_changes() {
1154        let result = crate::cow_rewrite::CowRewriteResult {
1155            removed_data_files: vec![],
1156            added_data_files: vec![],
1157            unchanged_data_files: vec![],
1158            stats: crate::cow_rewrite::CowRewriteStats {
1159                candidate_files: 0,
1160                rewritten_files: 0,
1161                unchanged_files: 0,
1162                input_rows: 0,
1163                output_rows: 0,
1164                changed_batches: 0,
1165            },
1166        };
1167
1168        assert!(!result.has_changes());
1169        assert_eq!(result.stats.candidate_files, 0);
1170    }
1171
1172    /// `DeleteIfModThree` keeps rows whose `id` is not divisible by 3.
1173    struct DeleteIfModThree;
1174
1175    impl CowBatchRewriter for DeleteIfModThree {
1176        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
1177            let ids = batch
1178                .column_by_name("id")
1179                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id column"))?
1180                .as_any()
1181                .downcast_ref::<Int32Array>()
1182                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be Int32"))?;
1183
1184            let keep =
1185                BooleanArray::from_iter((0..ids.len()).map(|row| Some(ids.value(row) % 3 != 0)));
1186            let filtered = arrow_select::filter::filter_record_batch(&batch, &keep)
1187                .map_err(|err| Error::new(ErrorKind::Unexpected, err.to_string()))?;
1188
1189            Ok(CowBatchRewrite {
1190                changed: filtered.num_rows() != batch.num_rows(),
1191                output: (filtered.num_rows() > 0).then_some(filtered),
1192            })
1193        }
1194    }
1195
1196    /// After adding an optional column (`value`) to the schema, a COW rewrite
1197    /// of the pre-existing data must not fail. The replacement writer must use
1198    /// the schema the batches were read in (the snapshot schema), not the
1199    /// table's evolved current schema, otherwise the parquet writer rejects
1200    /// the input batch whose column set does not include `value`.
1201    #[tokio::test]
1202    async fn cow_rewrite_after_optional_column_add() -> Result<()> {
1203        let temp_dir = TempDir::new().unwrap();
1204        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
1205        let catalog: Arc<dyn Catalog> = Arc::new(
1206            MemoryCatalogBuilder::default()
1207                .with_storage_factory(Arc::new(LocalFsStorageFactory))
1208                .load(
1209                    "memory",
1210                    HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
1211                )
1212                .await?,
1213        );
1214        let namespace = NamespaceIdent::new("ns".to_string());
1215        catalog.create_namespace(&namespace, HashMap::new()).await?;
1216
1217        let schema = Schema::builder()
1218            .with_fields(vec![
1219                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1220            ])
1221            .build()?;
1222        let table = catalog
1223            .create_table(
1224                &namespace,
1225                TableCreation::builder()
1226                    .name("evolved".to_string())
1227                    .schema(schema)
1228                    .build(),
1229            )
1230            .await?;
1231
1232        // Write the original data file with the {id}-only schema.
1233        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1234            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1235                PARQUET_FIELD_ID_META_KEY.to_string(),
1236                "1".to_string(),
1237            )])),
1238        ]));
1239        let batch = RecordBatch::try_new(arrow_schema, vec![Arc::new(Int32Array::from(vec![
1240            1, 2, 3, 4,
1241        ])) as ArrayRef])?;
1242        let data_files = super::writer::write_replacement_batches(
1243            &table,
1244            table.metadata().current_schema().clone(),
1245            None,
1246            futures::stream::iter(vec![Ok(batch)]),
1247        )
1248        .await?;
1249        let tx = Transaction::new(&table);
1250        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
1251        let table = tx.commit(&*catalog).await?;
1252
1253        // Evolve the schema: add an optional `value` column.
1254        let tx = Transaction::new(&table);
1255        let tx = tx
1256            .update_schema()
1257            .add_column(AddColumn::optional(
1258                "value",
1259                Type::Primitive(PrimitiveType::Int),
1260            ))
1261            .apply(tx)?;
1262        let table = tx.commit(&*catalog).await?;
1263
1264        // The current schema now has {id, value} but the current snapshot's
1265        // schema is still the original {id} schema.
1266        let current_snapshot = table.metadata().current_snapshot().unwrap();
1267        assert_ne!(
1268            table.metadata().current_schema_id(),
1269            current_snapshot.schema_id().unwrap()
1270        );
1271
1272        // Before the fix this fails when the parquet writer rejects the {id}-only
1273        // batch while configured with the {id, value} current schema.
1274        let result = CowRewriteBuilder::new(&table)
1275            .with_predicate(crate::expr::Predicate::AlwaysTrue)
1276            .with_rewriter(Arc::new(DeleteIfModThree))
1277            .rewrite()
1278            .await?;
1279
1280        // id 3 is divisible by 3 and is dropped; remaining ids are 1, 2, 4.
1281        assert!(result.has_changes());
1282        assert_eq!(result.removed_data_files.len(), 1);
1283        assert_eq!(result.added_data_files.len(), 1);
1284
1285        let ids = read_ids(&table, &result.added_data_files).await?;
1286        assert_eq!(ids, vec![1, 2, 4]);
1287
1288        Ok(())
1289    }
1290
1291    /// Builds a two-file table whose files have disjoint id ranges so predicate
1292    /// metrics pruning can exclude one file entirely.
1293    async fn two_file_table_disjoint_ids() -> Result<CowRewriteFixture> {
1294        let temp_dir = TempDir::new().unwrap();
1295        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
1296        let catalog = MemoryCatalogBuilder::default()
1297            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1298            .load(
1299                "memory",
1300                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
1301            )
1302            .await?;
1303        let namespace = NamespaceIdent::new("ns".to_string());
1304        catalog.create_namespace(&namespace, HashMap::new()).await?;
1305
1306        let schema = Schema::builder()
1307            .with_fields(vec![
1308                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1309            ])
1310            .build()?;
1311        let table = catalog
1312            .create_table(
1313                &namespace,
1314                TableCreation::builder()
1315                    .name("disjoint".to_string())
1316                    .schema(schema)
1317                    .properties(HashMap::from([(
1318                        TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
1319                        "1".to_string(),
1320                    )]))
1321                    .build(),
1322            )
1323            .await?;
1324
1325        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1326            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1327                PARQUET_FIELD_ID_META_KEY.to_string(),
1328                "1".to_string(),
1329            )])),
1330        ]));
1331        // First batch ids 1..=4, second batch ids 100..=103. Force a split via
1332        // tiny target file size so each batch lands in its own data file.
1333        let input = vec![vec![1, 2, 3, 4], vec![100, 101, 102, 103]]
1334            .into_iter()
1335            .map(|ids| {
1336                Ok(RecordBatch::try_new(arrow_schema.clone(), vec![
1337                    Arc::new(Int32Array::from(ids)) as ArrayRef,
1338                ])?)
1339            });
1340        let data_files = super::writer::write_replacement_batches(
1341            &table,
1342            table.metadata().current_schema().clone(),
1343            None,
1344            futures::stream::iter(input),
1345        )
1346        .await?;
1347
1348        let tx = Transaction::new(&table);
1349        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
1350        let table = tx.commit(&catalog).await?;
1351
1352        Ok(CowRewriteFixture {
1353            _temp_dir: temp_dir,
1354            table,
1355        })
1356    }
1357
1358    /// Predicate-based planning must exclude files whose metrics cannot match
1359    /// the predicate. With `id > 50`, the file holding ids 1..=4 must never be
1360    /// planned as a candidate.
1361    #[tokio::test]
1362    async fn cow_rewrite_predicate_prunes_non_matching_files() -> Result<()> {
1363        let fixture = two_file_table_disjoint_ids().await?;
1364
1365        let predicate = crate::expr::Reference::new("id").greater_than(crate::spec::Datum::int(50));
1366
1367        let result = CowRewriteBuilder::new(&fixture.table)
1368            .with_predicate(predicate)
1369            .with_rewriter(Arc::new(KeepAll))
1370            .rewrite()
1371            .await?;
1372
1373        // Only the high-id file should be a candidate.
1374        assert_eq!(result.stats.candidate_files, 1);
1375        // KeepAll does not change anything, so the single candidate is unchanged.
1376        assert_eq!(result.removed_data_files.len(), 0);
1377        assert_eq!(result.added_data_files.len(), 0);
1378        assert_eq!(result.unchanged_data_files.len(), 1);
1379
1380        // The surviving candidate must be the high-id file (ids 100..=103).
1381        let ids = read_ids(&fixture.table, &result.unchanged_data_files).await?;
1382        assert_eq!(ids, vec![100, 101, 102, 103]);
1383
1384        Ok(())
1385    }
1386
1387    /// Rewrites every batch without altering rows, so every candidate file is
1388    /// replaced. Useful for exercising the replacement-writing path itself.
1389    struct TouchEveryBatch;
1390
1391    impl CowBatchRewriter for TouchEveryBatch {
1392        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
1393            Ok(CowBatchRewrite {
1394                output: Some(batch),
1395                changed: true,
1396            })
1397        }
1398    }
1399
1400    /// An end-to-end rewrite on an identity-partitioned table must preserve
1401    /// each source file's partition: the replacement file carries the same
1402    /// partition values and partition spec id, and lands under the same
1403    /// partition directory — including the null partition.
1404    #[tokio::test]
1405    async fn cow_rewrite_preserves_partitions_end_to_end() -> Result<()> {
1406        let temp_dir = TempDir::new().unwrap();
1407        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
1408        let catalog = MemoryCatalogBuilder::default()
1409            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1410            .load(
1411                "memory",
1412                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
1413            )
1414            .await?;
1415        let namespace = NamespaceIdent::new("ns".to_string());
1416        catalog.create_namespace(&namespace, HashMap::new()).await?;
1417
1418        let schema = Schema::builder()
1419            .with_fields(vec![
1420                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1421                NestedField::optional(2, "value", Type::Primitive(PrimitiveType::Int)).into(),
1422            ])
1423            .build()?;
1424        let partition_spec = crate::spec::PartitionSpec::builder(Arc::new(schema.clone()))
1425            .add_partition_field("value", "value", Transform::Identity)?
1426            .build()?;
1427        let table = catalog
1428            .create_table(
1429                &namespace,
1430                TableCreation::builder()
1431                    .name("partitioned".to_string())
1432                    .schema(schema)
1433                    .partition_spec(partition_spec.into_unbound())
1434                    .build(),
1435            )
1436            .await?;
1437
1438        // Two source files: ids 1, 2 in the `value=1` partition and ids 3, 4
1439        // in the null partition.
1440        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1441            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1442                PARQUET_FIELD_ID_META_KEY.to_string(),
1443                "1".to_string(),
1444            )])),
1445            Field::new("value", DataType::Int32, true).with_metadata(HashMap::from([(
1446                PARQUET_FIELD_ID_META_KEY.to_string(),
1447                "2".to_string(),
1448            )])),
1449        ]));
1450        let current_schema = table.metadata().current_schema().clone();
1451        let default_spec = table.metadata().default_partition_spec().as_ref().clone();
1452
1453        let batch_one = RecordBatch::try_new(arrow_schema.clone(), vec![
1454            Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
1455            Arc::new(Int32Array::from(vec![1, 1])),
1456        ])?;
1457        let one_partition = crate::spec::PartitionKey::new(
1458            default_spec.clone(),
1459            current_schema.clone(),
1460            Struct::from_iter([Some(Literal::int(1))]),
1461        );
1462        let files_one = super::writer::write_replacement_batches(
1463            &table,
1464            current_schema.clone(),
1465            Some(one_partition),
1466            futures::stream::iter(vec![Ok(batch_one)]),
1467        )
1468        .await?;
1469
1470        let batch_null = RecordBatch::try_new(arrow_schema.clone(), vec![
1471            Arc::new(Int32Array::from(vec![3, 4])) as ArrayRef,
1472            Arc::new(Int32Array::from(vec![None::<i32>, None])),
1473        ])?;
1474        let null_partition = crate::spec::PartitionKey::new(
1475            default_spec.clone(),
1476            current_schema.clone(),
1477            Struct::from_iter([None::<Literal>]),
1478        );
1479        let files_null = super::writer::write_replacement_batches(
1480            &table,
1481            current_schema.clone(),
1482            Some(null_partition),
1483            futures::stream::iter(vec![Ok(batch_null)]),
1484        )
1485        .await?;
1486
1487        let tx = Transaction::new(&table);
1488        let tx = tx
1489            .fast_append()
1490            .add_data_files([files_one, files_null].concat())
1491            .apply(tx)?;
1492        let table = tx.commit(&catalog).await?;
1493
1494        let result = CowRewriteBuilder::new(&table)
1495            .with_rewriter(Arc::new(TouchEveryBatch))
1496            .rewrite()
1497            .await?;
1498
1499        assert_eq!(result.stats.candidate_files, 2);
1500        assert_eq!(result.stats.rewritten_files, 2);
1501        assert_eq!(result.removed_data_files.len(), 2);
1502        assert_eq!(result.added_data_files.len(), 2);
1503        assert!(result.unchanged_data_files.is_empty());
1504
1505        // Partition values survive the rewrite unchanged.
1506        let one_struct = Struct::from_iter([Some(Literal::int(1))]);
1507        let null_struct = Struct::from_iter([None::<Literal>]);
1508        let mut removed_partitions = result
1509            .removed_data_files
1510            .iter()
1511            .map(|file| file.partition().clone())
1512            .collect::<Vec<_>>();
1513        let mut added_partitions = result
1514            .added_data_files
1515            .iter()
1516            .map(|file| file.partition().clone())
1517            .collect::<Vec<_>>();
1518        removed_partitions.sort_by_key(|partition| partition.is_null_at_index(0));
1519        added_partitions.sort_by_key(|partition| partition.is_null_at_index(0));
1520        assert_eq!(removed_partitions, added_partitions);
1521        assert_eq!(added_partitions[0], one_struct);
1522        assert_eq!(added_partitions[1], null_struct);
1523
1524        for file in &result.added_data_files {
1525            assert_eq!(file.partition_spec_id, 0);
1526            if file.partition() == &one_struct {
1527                assert!(file.file_path().contains("value=1"), "{}", file.file_path());
1528            } else {
1529                assert!(
1530                    file.file_path().contains("value=null"),
1531                    "{}",
1532                    file.file_path()
1533                );
1534            }
1535        }
1536
1537        // Replacement content matches the source rows.
1538        let ids = read_ids(&table, &result.added_data_files).await?;
1539        assert_eq!(ids, vec![1, 2, 3, 4]);
1540
1541        Ok(())
1542    }
1543
1544    /// Mutates the identity-partitioned `value` column while flagging the
1545    /// batch as changed — a violation of the partition-preservation contract.
1546    /// In debug builds the partition guard must trip instead of letting the
1547    /// replacement file record a partition that disagrees with its rows.
1548    #[cfg(debug_assertions)]
1549    struct MutatePartitionColumn;
1550
1551    #[cfg(debug_assertions)]
1552    impl CowBatchRewriter for MutatePartitionColumn {
1553        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> {
1554            let values = batch
1555                .column_by_name("value")
1556                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing value column"))?
1557                .as_any()
1558                .downcast_ref::<Int32Array>()
1559                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must be Int32"))?;
1560            let updated =
1561                Int32Array::from_iter((0..values.len()).map(|row| Some(values.value(row) + 100)));
1562            let output = RecordBatch::try_new(batch.schema(), vec![
1563                batch.column(0).clone(),
1564                Arc::new(updated),
1565            ])
1566            .map_err(|err| Error::new(ErrorKind::Unexpected, err.to_string()))?;
1567
1568            Ok(CowBatchRewrite {
1569                output: Some(output),
1570                changed: true,
1571            })
1572        }
1573    }
1574
1575    #[cfg(debug_assertions)]
1576    #[tokio::test]
1577    #[should_panic(expected = "must preserve the source file's partition values")]
1578    async fn cow_rewrite_partition_column_mutation_trips_debug_guard() {
1579        let temp_dir = TempDir::new().unwrap();
1580        let warehouse = format!("file://{}", temp_dir.path().join("warehouse").display());
1581        let catalog = MemoryCatalogBuilder::default()
1582            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1583            .load(
1584                "memory",
1585                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), warehouse)]),
1586            )
1587            .await
1588            .unwrap();
1589        let namespace = NamespaceIdent::new("ns".to_string());
1590        catalog
1591            .create_namespace(&namespace, HashMap::new())
1592            .await
1593            .unwrap();
1594
1595        let schema = Schema::builder()
1596            .with_fields(vec![
1597                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1598                NestedField::required(2, "value", Type::Primitive(PrimitiveType::Int)).into(),
1599            ])
1600            .build()
1601            .unwrap();
1602        let partition_spec = crate::spec::PartitionSpec::builder(Arc::new(schema.clone()))
1603            .add_partition_field("value", "value", Transform::Identity)
1604            .unwrap()
1605            .build()
1606            .unwrap();
1607        let table = catalog
1608            .create_table(
1609                &namespace,
1610                TableCreation::builder()
1611                    .name("partition_guard".to_string())
1612                    .schema(schema)
1613                    .partition_spec(partition_spec.into_unbound())
1614                    .build(),
1615            )
1616            .await
1617            .unwrap();
1618
1619        // One source file whose rows all sit in the `value=1` partition.
1620        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1621            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1622                PARQUET_FIELD_ID_META_KEY.to_string(),
1623                "1".to_string(),
1624            )])),
1625            Field::new("value", DataType::Int32, false).with_metadata(HashMap::from([(
1626                PARQUET_FIELD_ID_META_KEY.to_string(),
1627                "2".to_string(),
1628            )])),
1629        ]));
1630        let batch = RecordBatch::try_new(arrow_schema, vec![
1631            Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
1632            Arc::new(Int32Array::from(vec![1, 1])),
1633        ])
1634        .unwrap();
1635        let partition_key = crate::spec::PartitionKey::new(
1636            table.metadata().default_partition_spec().as_ref().clone(),
1637            table.metadata().current_schema().clone(),
1638            Struct::from_iter([Some(Literal::int(1))]),
1639        );
1640        let data_files = super::writer::write_replacement_batches(
1641            &table,
1642            table.metadata().current_schema().clone(),
1643            Some(partition_key),
1644            futures::stream::iter(vec![Ok(batch)]),
1645        )
1646        .await
1647        .unwrap();
1648
1649        let tx = Transaction::new(&table);
1650        let tx = tx
1651            .fast_append()
1652            .add_data_files(data_files)
1653            .apply(tx)
1654            .unwrap();
1655        let table = tx.commit(&catalog).await.unwrap();
1656
1657        // The rewriter bumps every `value` by 100, so the output batch
1658        // computes to a different partition than the source file records.
1659        // The debug guard must panic here; without it the rewrite would
1660        // silently commit a replacement file under `value=1` holding rows
1661        // with `value=101`.
1662        let _ = CowRewriteBuilder::new(&table)
1663            .with_rewriter(Arc::new(MutatePartitionColumn))
1664            .rewrite()
1665            .await;
1666    }
1667}