Skip to main content

ailake_query/
equality_delete.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Equality delete filter — Phase H.
3//!
4//! Loads Iceberg equality delete files from the object store and builds an in-memory
5//! predicate set. Applied to each `RecordBatch` during scan to mask logically deleted rows.
6//!
7//! Scope: single-column equality predicates (most common pattern: document_id, agent_id,
8//! session_id). Multi-column AND predicates are supported as long as each column is checked
9//! independently (conservative: a row is deleted if ALL delete-file columns match).
10
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use ailake_catalog::{read_equality_delete_values, EqualityDeleteFile};
15use ailake_core::{AilakeError, AilakeResult};
16use ailake_store::Store;
17use arrow_array::{
18    Array, BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch,
19    StringArray,
20};
21use arrow_schema::DataType;
22
23/// In-memory equality delete filter built from one or more delete files.
24///
25/// Each entry is one source delete file's own sequence number plus its
26/// `column_name → set of string-normalised values to delete`. A data row is
27/// deleted by a given entry if, for every column in that entry's predicate,
28/// the row's value is a member of that column's set (AND, not OR) — **and**
29/// the entry's sequence number is strictly greater than the data file's own
30/// (Iceberg spec: a delete only applies to files committed strictly before
31/// it). Kept as a `Vec` of per-file predicates rather than one merged map —
32/// unlike a plain column→values union, the per-file sequence number can't be
33/// collapsed away: two delete files touching the same column need their
34/// masks evaluated against different data-file sequence numbers.
35pub struct EqualityDeleteFilter {
36    /// (delete file's sequence_number, column_name → values to delete)
37    filters: Vec<(i64, HashMap<String, HashSet<String>>)>,
38}
39
40impl EqualityDeleteFilter {
41    /// Build filter from a list of equality delete file references.
42    ///
43    /// For each file, downloads the Avro payload from `store`, extracts
44    /// `(column_name, value)` pairs, and keeps the file's own
45    /// `EqualityDeleteFile::sequence_number` alongside — needed later so
46    /// `should_delete_row`/`apply` can skip predicates that don't apply to
47    /// the data file currently being scanned.
48    pub async fn from_files(
49        store: &Arc<dyn Store>,
50        files: &[EqualityDeleteFile],
51    ) -> AilakeResult<Self> {
52        let mut filters: Vec<(i64, HashMap<String, HashSet<String>>)> = Vec::new();
53        for edf in files {
54            let bytes = store.get(&edf.path).await?;
55            let pairs = read_equality_delete_values(&bytes)
56                .map_err(|e| AilakeError::Catalog(e.to_string()))?;
57            let mut cols: HashMap<String, HashSet<String>> = HashMap::new();
58            for (col, val) in pairs {
59                cols.entry(col).or_default().insert(val);
60            }
61            filters.push((edf.sequence_number, cols));
62        }
63        Ok(Self { filters })
64    }
65
66    pub fn empty() -> Self {
67        Self {
68            filters: Vec::new(),
69        }
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.filters.is_empty()
74    }
75
76    /// Whether `batch`'s row at `row_idx` matches the AND-predicate of a single
77    /// delete file's column→values map. Split out of `should_delete_row` so the
78    /// sequence-number gate stays the only thing that differs per delete entry.
79    fn row_matches(
80        cols: &HashMap<String, HashSet<String>>,
81        batch: &RecordBatch,
82        row_idx: usize,
83    ) -> bool {
84        let mut any_column_found = false;
85        for (col_name, delete_values) in cols {
86            let col_idx = match batch.schema().index_of(col_name.as_str()) {
87                Ok(i) => i,
88                Err(_) => continue, // column absent — skip (schema evolution)
89            };
90            any_column_found = true;
91            let array = batch.column(col_idx);
92            if array.is_null(row_idx) {
93                return false; // null never matches — AND tuple fails
94            }
95            let val_str: Option<String> = match array.data_type() {
96                DataType::Utf8 => array
97                    .as_any()
98                    .downcast_ref::<StringArray>()
99                    .map(|a| a.value(row_idx).to_string()),
100                DataType::LargeUtf8 => array
101                    .as_any()
102                    .downcast_ref::<arrow_array::LargeStringArray>()
103                    .map(|a| a.value(row_idx).to_string()),
104                DataType::Int32 => array
105                    .as_any()
106                    .downcast_ref::<Int32Array>()
107                    .map(|a| a.value(row_idx).to_string()),
108                DataType::Int64 => array
109                    .as_any()
110                    .downcast_ref::<Int64Array>()
111                    .map(|a| a.value(row_idx).to_string()),
112                DataType::Float32 => array
113                    .as_any()
114                    .downcast_ref::<Float32Array>()
115                    .map(|a| a.value(row_idx).to_string()),
116                DataType::Float64 => array
117                    .as_any()
118                    .downcast_ref::<Float64Array>()
119                    .map(|a| a.value(row_idx).to_string()),
120                _ => None,
121            };
122            match val_str {
123                Some(s) if delete_values.contains(&s) => {} // column matches, continue AND check
124                Some(_) => return false,                    // column mismatch — AND tuple fails
125                None => {}                                  // unknown type — skip column
126            }
127        }
128        any_column_found // true only when all checked columns matched
129    }
130
131    /// Check whether a single row (by its physical index in `batch`) matches any
132    /// applicable delete predicate.
133    ///
134    /// Returns `true` if the row should be logically deleted. `data_file_sequence_number`
135    /// is the sequence number of the data file `batch` was read from
136    /// (`DataFileEntry::sequence_number`) — a delete predicate only applies when its own
137    /// sequence number is strictly greater than this, per Iceberg spec. This is what lets
138    /// an insert and a delete of the same key committed in the *same* snapshot (equal
139    /// sequence numbers) leave the insert visible, instead of the delete masking the row
140    /// it was committed alongside.
141    pub fn should_delete_row(
142        &self,
143        batch: &RecordBatch,
144        row_idx: usize,
145        data_file_sequence_number: i64,
146    ) -> bool {
147        for (delete_seq, cols) in &self.filters {
148            if *delete_seq <= data_file_sequence_number {
149                continue; // delete does not apply to this (equal-or-newer) data file
150            }
151            if Self::row_matches(cols, batch, row_idx) {
152                return true;
153            }
154        }
155        false
156    }
157
158    /// Apply the filter to `batch`, returning a new batch with matching rows removed.
159    ///
160    /// `data_file_sequence_number` — see `should_delete_row` doc.
161    pub fn apply(
162        &self,
163        batch: RecordBatch,
164        data_file_sequence_number: i64,
165    ) -> AilakeResult<RecordBatch> {
166        if self.filters.is_empty() {
167            return Ok(batch);
168        }
169        let n = batch.num_rows();
170        let keep: Vec<bool> = (0..n)
171            .map(|i| !self.should_delete_row(&batch, i, data_file_sequence_number))
172            .collect();
173        let mask = BooleanArray::from(keep);
174        arrow_select::filter::filter_record_batch(&batch, &mask)
175            .map_err(|e| AilakeError::Arrow(e.to_string()))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use std::sync::Arc;
182
183    use arrow_array::{Int32Array, RecordBatch, StringArray};
184    use arrow_schema::{DataType, Field, Schema};
185
186    use super::EqualityDeleteFilter;
187    use std::collections::{HashMap, HashSet};
188
189    fn make_batch() -> RecordBatch {
190        let schema = Arc::new(Schema::new(vec![
191            Field::new("doc_id", DataType::Utf8, true),
192            Field::new("score", DataType::Int32, true),
193        ]));
194        RecordBatch::try_new(
195            schema,
196            vec![
197                Arc::new(StringArray::from(vec!["doc-a", "doc-b", "doc-c", "doc-d"])),
198                Arc::new(Int32Array::from(vec![1, 2, 3, 4])),
199            ],
200        )
201        .unwrap()
202    }
203
204    /// Wraps a single-file predicate at `delete_seq=1` — existing tests below call
205    /// `.apply(batch, 0)`/`.should_delete_row(.., 0)` so the delete (seq 1) always
206    /// applies to the data (seq 0), preserving their original pre-sequence-scoping
207    /// behavior. The scoping behavior itself is covered by the tests further down.
208    fn filter_with(filters: HashMap<String, HashSet<String>>) -> EqualityDeleteFilter {
209        EqualityDeleteFilter {
210            filters: vec![(1, filters)],
211        }
212    }
213
214    #[test]
215    fn empty_filter_is_no_op() {
216        let batch = make_batch();
217        let f = filter_with(HashMap::new());
218        let result = f.apply(batch.clone(), 0).unwrap();
219        assert_eq!(result.num_rows(), 4);
220    }
221
222    #[test]
223    fn single_value_deleted() {
224        let mut filters = HashMap::new();
225        filters.insert("doc_id".into(), ["doc-b".to_string()].into());
226        let f = filter_with(filters);
227        let result = f.apply(make_batch(), 0).unwrap();
228        assert_eq!(result.num_rows(), 3);
229        let ids = result
230            .column(0)
231            .as_any()
232            .downcast_ref::<StringArray>()
233            .unwrap();
234        assert_eq!(ids.value(0), "doc-a");
235        assert_eq!(ids.value(1), "doc-c");
236        assert_eq!(ids.value(2), "doc-d");
237    }
238
239    #[test]
240    fn multiple_values_deleted() {
241        let mut filters = HashMap::new();
242        filters.insert(
243            "doc_id".into(),
244            ["doc-a".to_string(), "doc-c".to_string()].into(),
245        );
246        let f = filter_with(filters);
247        let result = f.apply(make_batch(), 0).unwrap();
248        assert_eq!(result.num_rows(), 2);
249        let ids = result
250            .column(0)
251            .as_any()
252            .downcast_ref::<StringArray>()
253            .unwrap();
254        assert_eq!(ids.value(0), "doc-b");
255        assert_eq!(ids.value(1), "doc-d");
256    }
257
258    #[test]
259    fn column_absent_from_batch_is_skipped() {
260        let mut filters = HashMap::new();
261        filters.insert("nonexistent_col".into(), ["x".to_string()].into());
262        let f = filter_with(filters);
263        let result = f.apply(make_batch(), 0).unwrap();
264        assert_eq!(result.num_rows(), 4); // no rows deleted
265    }
266
267    #[test]
268    fn numeric_column_deletion() {
269        let mut filters = HashMap::new();
270        filters.insert("score".into(), ["2".to_string(), "4".to_string()].into());
271        let f = filter_with(filters);
272        let result = f.apply(make_batch(), 0).unwrap();
273        assert_eq!(result.num_rows(), 2);
274        let ids = result
275            .column(0)
276            .as_any()
277            .downcast_ref::<StringArray>()
278            .unwrap();
279        assert_eq!(ids.value(0), "doc-a");
280        assert_eq!(ids.value(1), "doc-c");
281    }
282
283    // ── Sequence-number scoping (the actual bug fix) ────────────────────────────
284
285    #[test]
286    fn delete_does_not_mask_a_data_file_with_equal_sequence_number() {
287        // Same-snapshot insert+delete of the same key (e.g. an upsert emulated as
288        // delete-then-insert in one commit): both land at the same sequence number.
289        // Per Iceberg spec (data_seq < delete_seq to mask), an equal sequence number
290        // must NOT mask the row — otherwise a real upsert could never make a row
291        // reappear after "deleting" the stale version in the same transaction.
292        let mut filters = HashMap::new();
293        filters.insert("doc_id".into(), ["doc-b".to_string()].into());
294        let f = EqualityDeleteFilter {
295            filters: vec![(5, filters)],
296        };
297        let result = f.apply(make_batch(), 5).unwrap();
298        assert_eq!(result.num_rows(), 4, "equal sequence number must not mask");
299    }
300
301    #[test]
302    fn delete_does_not_mask_a_data_file_committed_after_it() {
303        // A data file with a HIGHER sequence number than the delete was committed
304        // later — e.g. a brand new, unrelated row that happens to reuse a
305        // previously-deleted key. The old delete must not reach forward in time
306        // and mask it.
307        let mut filters = HashMap::new();
308        filters.insert("doc_id".into(), ["doc-b".to_string()].into());
309        let f = EqualityDeleteFilter {
310            filters: vec![(2, filters)],
311        };
312        let result = f.apply(make_batch(), 5).unwrap();
313        assert_eq!(
314            result.num_rows(),
315            4,
316            "a delete must not mask a data file committed after it"
317        );
318    }
319
320    #[test]
321    fn delete_masks_only_data_files_committed_strictly_before_it() {
322        let mut filters = HashMap::new();
323        filters.insert("doc_id".into(), ["doc-b".to_string()].into());
324        let f = EqualityDeleteFilter {
325            filters: vec![(5, filters)],
326        };
327        let result = f.apply(make_batch(), 0).unwrap();
328        assert_eq!(result.num_rows(), 3, "older data file must still be masked");
329    }
330
331    #[test]
332    fn multiple_delete_files_each_scoped_to_their_own_sequence_number() {
333        let mut del_a = HashMap::new();
334        del_a.insert("doc_id".into(), ["doc-a".to_string()].into());
335        let mut del_c = HashMap::new();
336        del_c.insert("doc_id".into(), ["doc-c".to_string()].into());
337        // doc-a deleted at seq=1 (masks anything committed before seq 1);
338        // doc-c deleted at seq=10 (masks anything committed before seq 10).
339        let f = EqualityDeleteFilter {
340            filters: vec![(1, del_a), (10, del_c)],
341        };
342        // A data file at seq=5: too new for the doc-a delete (1 <= 5), but the
343        // doc-c delete (10 > 5) still applies.
344        let result = f.apply(make_batch(), 5).unwrap();
345        let ids = result
346            .column(0)
347            .as_any()
348            .downcast_ref::<StringArray>()
349            .unwrap();
350        let survivors: Vec<&str> = (0..result.num_rows()).map(|i| ids.value(i)).collect();
351        assert_eq!(survivors, vec!["doc-a", "doc-b", "doc-d"]);
352    }
353}