Skip to main content

lance_table/transaction/
manifest_build.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Applying an operation to produce the next manifest.
5//!
6//! [`Transaction::build_manifest`] is the centre of this module and of the
7//! transaction machinery generally: given the current manifest and index list, it
8//! decides the new fragment list, the surviving indices and the next row id, then
9//! assembles the manifest. Everything else in `super` exists to serve it -- the
10//! operation vocabulary it matches on, the index rules it applies, the row version
11//! metadata it stamps, the validation that runs before it.
12
13use crate::feature_flags::{
14    FLAG_COVERED_INDEX_METADATA, FLAG_STABLE_ROW_IDS, apply_feature_flags,
15    ensure_can_read_manifest, ensure_can_write_manifest, inherit_sticky_feature_flags,
16};
17use crate::format::overlay::{OverlayCoverage, TOMBSTONE_FIELD_ID};
18use crate::format::{
19    DataFile, DataStorageFormat, Fragment, IndexMetadata, Manifest, ManifestBuildConfig,
20    overlay::DataOverlayFile,
21};
22use crate::io::{
23    commit::CommitHandler,
24    manifest::{read_manifest, read_manifest_indexes},
25};
26use crate::rowids::version::build_version_meta;
27use crate::system_index::is_system_index;
28use crate::system_index::mem_wal::{
29    CompactedSsTable, IndexCatchupProgress, MEM_WAL_INDEX_NAME, load_mem_wal_index_details,
30    new_mem_wal_index_meta, update_mem_wal_index_compacted_sstables,
31};
32use crate::transaction::UpdateMode::{RewriteColumns, RewriteRows};
33use crate::transaction::row_version::resolve_update_version_metadata;
34use crate::transaction::update_map::apply_update_map;
35use crate::transaction::validate::merge_fragment_physically_rewritten;
36use crate::transaction::{
37    CoverageIdentity, DataReplacementGroup, LogicalIndexSegments, Operation, ReadVersionState,
38    RewriteGroup, Transaction, UpdatedFragmentOffsets,
39};
40use lance_core::datatypes::{
41    LANCE_UNENFORCED_CLUSTERING_KEY_POSITION, LANCE_UNENFORCED_PRIMARY_KEY,
42    LANCE_UNENFORCED_PRIMARY_KEY_POSITION,
43};
44use lance_core::utils::parse::str_is_truthy;
45use lance_core::{Error, Result};
46use lance_file::version::ConcreteFileVersion;
47use lance_io::object_store::ObjectStore;
48use object_store::path::Path;
49use roaring::RoaringBitmap;
50use std::collections::{BTreeMap, HashMap, HashSet};
51use std::sync::Arc;
52use uuid::Uuid;
53
54impl Transaction {
55    pub(super) fn fragments_with_ids<'a, T>(
56        new_fragments: T,
57        fragment_id: &'a mut u64,
58    ) -> impl Iterator<Item = Fragment> + 'a
59    where
60        T: IntoIterator<Item = Fragment> + 'a,
61    {
62        new_fragments.into_iter().map(move |mut f| {
63            if f.id == 0 {
64                f.id = *fragment_id;
65                *fragment_id += 1;
66            }
67            f
68        })
69    }
70
71    fn data_storage_format_from_files(
72        fragments: &[Fragment],
73        user_requested: Option<ConcreteFileVersion>,
74    ) -> Result<DataStorageFormat> {
75        // Mixed prewritten files cannot imply a default. An explicit default
76        // takes precedence; finalization validates the referenced file versions.
77        let version = match user_requested {
78            Some(ConcreteFileVersion::V1) => {
79                // Preserve legacy creation's homogeneous-file contract.
80                if let Some(actual) = Fragment::try_infer_version(fragments)?
81                    && actual != ConcreteFileVersion::V1
82                {
83                    return Err(Error::invalid_input(format!(
84                        "User requested data storage version ({}) does not match version in data files ({actual})",
85                        ConcreteFileVersion::V1
86                    )));
87                }
88                Some(ConcreteFileVersion::V1)
89            }
90            Some(version) => Some(version),
91            None => Fragment::try_infer_version(fragments)?,
92        };
93        Ok(version.map(DataStorageFormat::new).unwrap_or_default())
94    }
95
96    pub async fn restore_old_manifest(
97        object_store: &ObjectStore,
98        commit_handler: &dyn CommitHandler,
99        base_path: &Path,
100        version: u64,
101        config: &ManifestBuildConfig,
102        tx_path: &str,
103        current_manifest: &Manifest,
104    ) -> Result<(Manifest, Vec<IndexMetadata>)> {
105        let location = commit_handler
106            .resolve_version_location(base_path, version, &object_store.inner)
107            .await?;
108        let mut manifest = read_manifest(object_store, &location.path, location.size).await?;
109        // This read bypasses Dataset's feature gates. Refuse unsupported target
110        // manifests before apply_feature_flags can clear their unknown bits and
111        // republish the referenced files as legacy-compatible.
112        ensure_can_read_manifest(&manifest)?;
113        ensure_can_write_manifest(&manifest)?;
114        manifest.set_timestamp(config.timestamp_nanos);
115        manifest.transaction_file = Some(tx_path.to_string());
116        let indices = read_manifest_indexes(object_store, &location, &manifest).await?;
117        manifest.max_fragment_id = manifest
118            .max_fragment_id
119            .max(current_manifest.max_fragment_id);
120        // Row ids are a high-water mark like fragment ids: rewinding hands old ids to new rows.
121        manifest.next_row_id = manifest.next_row_id.max(current_manifest.next_row_id);
122        // Turning stable row ids off would revert `_rowid` to row addresses, whose
123        // namespace overlaps the ids this table has already handed out.
124        if current_manifest.uses_stable_row_ids() && !manifest.uses_stable_row_ids() {
125            return Err(Error::invalid_input(format!(
126                "Cannot restore version {version}: stable row ids were enabled \
127                 after it, and turning them back off would let row addresses \
128                 collide with ids this table has already used"
129            )));
130        }
131        inherit_sticky_feature_flags(&mut manifest, current_manifest)?;
132        Ok((manifest, indices))
133    }
134
135    /// Every non-system logical index, mapped to what determines its coverage.
136    ///
137    /// A logical index may be backed by several physical segments, so "did this
138    /// index change" is a question about the whole set. Sorted by UUID so the
139    /// two sides compare positionally.
140    pub fn logical_index_segments(indices: &[IndexMetadata]) -> LogicalIndexSegments {
141        let mut by_name: LogicalIndexSegments = BTreeMap::new();
142        for idx in indices.iter().filter(|idx| !is_system_index(idx)) {
143            by_name
144                .entry(idx.name.clone())
145                .or_default()
146                .push(CoverageIdentity {
147                    uuid: idx.uuid,
148                    fragment_bitmap: idx.fragment_bitmap.clone(),
149                });
150        }
151        for segments in by_name.values_mut() {
152            segments.sort_unstable_by_key(|segment| segment.uuid);
153        }
154        by_name
155    }
156
157    /// Apply MemWAL index-coverage rules once the final index list is known.
158    ///
159    /// Coverage records that a base-table index contains the rows a compaction
160    /// copied in, and the WAL pod retires SSTables against it.
161    ///
162    /// It is derived, not reported. An index covering every fragment live at the
163    /// transaction's read version holds every row compaction had copied in by
164    /// then, so it is caught up to that version's `compacted_sstables`. That is
165    /// the only proof available: nothing maps a generation to the fragments its
166    /// rows landed in, so covering the table as the transaction read it is how
167    /// an index shows it covered those rows. Fragments appended since are a
168    /// later gap.
169    ///
170    /// Deriving rather than transmitting means no claim can go stale between
171    /// inspection and commit, the answer survives rebase (`read_version` is
172    /// fixed for a transaction's life), and any operation can earn coverage --
173    /// an ordinary reindex that fully covers no longer has to throw its work
174    /// away and wait for a repair.
175    ///
176    /// Only meaningful once catch-up is required, where a missing entry means
177    /// "not caught up" and the SSTables stay. A legacy table reads a missing
178    /// entry as "fully caught up", so this leaves it untouched rather than
179    /// making the table look more covered than it is.
180    pub fn apply_mem_wal_index_coverage(
181        final_indices: &mut [IndexMetadata],
182        segments_before: &LogicalIndexSegments,
183        read_version_state: Option<ReadVersionState<'_>>,
184        new_version: u64,
185    ) -> Result<()> {
186        let Some(pos) = final_indices
187            .iter()
188            .position(|idx| idx.name == MEM_WAL_INDEX_NAME)
189        else {
190            // The system index went away with this transaction (MemWAL disable,
191            // or an overwrite). There is no coverage left to maintain.
192            return Ok(());
193        };
194
195        let mut details = load_mem_wal_index_details(final_indices[pos].clone())?;
196
197        // Nothing has ever been compacted, so no index can be behind and there
198        // is no coverage to invalidate.
199        if details.compacted_sstables.is_empty() && details.index_catchup.is_empty() {
200            return Ok(());
201        }
202
203        let segments_after = Self::logical_index_segments(final_indices);
204        let catchup_before = std::mem::take(&mut details.index_catchup);
205
206        // Per shard: what this commit records as compacted, and the most the
207        // read version may credit. Generations compacted after that read landed
208        // in fragments no index under consideration has seen; the committed
209        // value caps it in turn, so a read version since rolled back cannot
210        // retire SSTables no live commit copied in.
211        let read_details = read_version_state
212            .map(|state| {
213                state
214                    .indices
215                    .iter()
216                    .find(|idx| idx.name == MEM_WAL_INDEX_NAME)
217                    .cloned()
218                    .map(load_mem_wal_index_details)
219                    .transpose()
220            })
221            .transpose()?
222            .flatten();
223        let shards: Vec<(Uuid, u64, u64)> = details
224            .compacted_sstables
225            .iter()
226            .map(|committed| {
227                let at_read = read_details
228                    .as_ref()
229                    .and_then(|read| {
230                        read.compacted_sstables
231                            .iter()
232                            .find(|s| s.shard_id == committed.shard_id)
233                    })
234                    .map_or(0, |s| s.generation);
235                (
236                    committed.shard_id,
237                    committed.generation,
238                    at_read.min(committed.generation),
239                )
240            })
241            .collect();
242
243        // Every fragment live when the transaction read the table. An index
244        // spanning all of them holds every row compacted by then.
245        let read_fragments: Option<RoaringBitmap> = read_version_state.map(|state| {
246            state
247                .manifest
248                .fragments
249                .iter()
250                .map(|fragment| fragment.id as u32)
251                .collect()
252        });
253
254        let covers_read_version = |segments: &[CoverageIdentity]| -> bool {
255            let Some(required) = read_fragments.as_ref() else {
256                return false;
257            };
258            if required.is_empty() {
259                // Subset-of-empty is trivially true, so this would credit every
260                // index on a table with no fragments. Refused because an empty
261                // fragment list is not only what an emptied table looks like:
262                // it is also what a manifest written before #8438 looks like,
263                // where UpdateMemWalState published no fragments at all. On
264                // such a table the SSTables are the last copy of those rows,
265                // and crediting coverage would retire them. The cost is that a
266                // genuinely emptied table keeps its SSTables.
267                return false;
268            }
269            let mut covered = RoaringBitmap::new();
270            for segment in segments {
271                match segment.fragment_bitmap.as_ref() {
272                    Some(bitmap) => covered |= bitmap,
273                    // An unknown bitmap cannot be shown to cover anything.
274                    None => return false,
275                }
276            }
277            required.is_subset(&covered)
278        };
279
280        let mut rebuilt: Vec<IndexCatchupProgress> = Vec::new();
281        for (name, after) in segments_after.iter() {
282            // Compared by [`CoverageIdentity`], not segment UUID: an Update
283            // that touches an indexed field prunes a segment's fragment bitmap
284            // in place while keeping its UUID, so a UUID-only comparison would
285            // carry a position forward that the index no longer earns.
286            let unchanged = segments_before.get(name) == Some(after);
287            let carried = unchanged
288                .then(|| catchup_before.iter().find(|e| e.index_name == *name))
289                .flatten();
290            let proven = covers_read_version(after);
291
292            if carried.is_none() && !proven {
293                // Changed, and nothing shows the new index covers the read
294                // version. No entry: a missing one reads as "not caught up".
295                continue;
296            }
297
298            let generations = shards
299                .iter()
300                .map(|&(shard_id, committed, creditable)| {
301                    let prior = carried
302                        .and_then(|entry| entry.caught_up_generation_for_shard(&shard_id))
303                        .unwrap_or(0);
304                    let credited = if proven { creditable } else { 0 };
305                    // Takes the better of what this commit proves and what an
306                    // unchanged index already held, so a commit reading an older
307                    // version does not lower a position it cannot re-prove. The
308                    // clamp is the exception: a position above what this commit
309                    // records as compacted describes rows no live commit copied
310                    // in.
311                    CompactedSsTable::new(shard_id, prior.max(credited).min(committed))
312                })
313                .collect::<Vec<_>>();
314            if generations.iter().all(|g| g.generation == 0) {
315                continue;
316            }
317            rebuilt.push(IndexCatchupProgress::new(name.clone(), generations));
318        }
319        rebuilt.sort_by(|a, b| a.index_name.cmp(&b.index_name));
320
321        let mut before_sorted = catchup_before;
322        before_sorted.sort_by(|a, b| a.index_name.cmp(&b.index_name));
323        if rebuilt == before_sorted {
324            return Ok(());
325        }
326
327        let dropped: Vec<&str> = before_sorted
328            .iter()
329            .map(|e| e.index_name.as_str())
330            .filter(|name| !rebuilt.iter().any(|kept| kept.index_name == *name))
331            .collect();
332        if !dropped.is_empty() {
333            // The first thing to check when SSTables stop becoming trimmable.
334            log::info!(
335                "MemWAL index catch-up invalidated at version {new_version} for {dropped:?}: \
336                 these indices changed and no longer cover the version this commit read"
337            );
338        }
339
340        details.index_catchup = rebuilt;
341        final_indices[pos] = new_mem_wal_index_meta(new_version, details)?;
342        Ok(())
343    }
344
345    /// Drop coverage for indices a post-`build_manifest` step narrowed.
346    ///
347    /// The derivation runs while the manifest is being built, but the index list
348    /// is not final there: `migrate_indices` can recalculate a segment's
349    /// fragment bitmap and keep its UUID, so an index can narrow after its
350    /// position was decided. It reports which ones it touched rather than the
351    /// caller re-snapshotting every bitmap to find out. Only ever removes.
352    pub fn withdraw_coverage_invalidated_after_build(
353        indices: &mut [IndexMetadata],
354        changed: &[String],
355        new_version: u64,
356    ) -> Result<()> {
357        if changed.is_empty() {
358            return Ok(());
359        }
360        let Some(pos) = indices
361            .iter()
362            .position(|idx| idx.name == MEM_WAL_INDEX_NAME)
363        else {
364            return Ok(());
365        };
366        let mut details = load_mem_wal_index_details(indices[pos].clone())?;
367        let before = details.index_catchup.len();
368        details
369            .index_catchup
370            .retain(|entry| !changed.contains(&entry.index_name));
371        if details.index_catchup.len() == before {
372            return Ok(());
373        }
374        log::info!(
375            "MemWAL index catch-up withdrawn at version {new_version} for {changed:?}: \
376             these indices were recalculated after their coverage was derived"
377        );
378        indices[pos] = new_mem_wal_index_meta(new_version, details)?;
379        Ok(())
380    }
381
382    /// Create a new manifest from the current manifest and the transaction.
383    ///
384    /// `current_manifest` should only be None if the dataset does not yet exist.
385    pub fn build_manifest(
386        &self,
387        current_manifest: Option<&Manifest>,
388        current_indices: Vec<IndexMetadata>,
389        transaction_file_path: &str,
390        config: &ManifestBuildConfig,
391    ) -> Result<(Manifest, Vec<IndexMetadata>)> {
392        self.build_manifest_with_read_version(
393            current_manifest,
394            current_indices,
395            transaction_file_path,
396            config,
397            None,
398        )
399    }
400
401    /// [`Self::build_manifest`] with the version this transaction read.
402    ///
403    /// Supplied by the commit path, which already materializes that version.
404    /// `None` where there is none to read -- dataset creation and detached
405    /// commits -- in which case no index can be shown to cover it and coverage
406    /// is left as the invalidation rules put it.
407    pub fn build_manifest_with_read_version(
408        &self,
409        current_manifest: Option<&Manifest>,
410        current_indices: Vec<IndexMetadata>,
411        transaction_file_path: &str,
412        config: &ManifestBuildConfig,
413        read_version_state: Option<ReadVersionState<'_>>,
414    ) -> Result<(Manifest, Vec<IndexMetadata>)> {
415        if config.use_stable_row_ids
416            && config.migration_next_row_id.is_none()
417            && current_manifest
418                .map(|m| !m.uses_stable_row_ids())
419                .unwrap_or_default()
420        {
421            return Err(Error::not_supported_source(
422                "This dataset was not created with the stable row ids feature.  Please run `migrate_to_stable_row_ids` before attempting to use stable row ids".into(),
423            ));
424        }
425
426        if config.migration_next_row_id.is_some() && !current_indices.is_empty() {
427            let names: Vec<&str> = current_indices
428                .iter()
429                .map(|idx| idx.name.as_str())
430                .collect();
431            return Err(Error::invalid_input(format!(
432                "Cannot migrate to stable row IDs while indexes exist on the dataset. \
433                 Drop the following indexes first, then re-run the migration, and \
434                 recreate them afterwards: {}",
435                names.join(", ")
436            )));
437        }
438        let mut reference_paths = match current_manifest {
439            Some(m) => m.base_paths.clone(),
440            None => HashMap::new(),
441        };
442
443        if let Operation::Overwrite {
444            initial_bases: Some(initial_bases),
445            ..
446        } = &self.operation
447        {
448            if current_manifest.is_none() {
449                // CREATE mode: registering base paths
450                // Base IDs should have been assigned during write operation
451                // Validate uniqueness and insert them into the manifest
452                for base_path in initial_bases.iter() {
453                    if reference_paths.contains_key(&base_path.id) {
454                        return Err(Error::invalid_input(format!(
455                            "Duplicate base path ID {} detected. Base path IDs must be unique.",
456                            base_path.id
457                        )));
458                    }
459                    reference_paths.insert(base_path.id, base_path.clone());
460                }
461            } else {
462                // OVERWRITE mode with initial_bases should have been rejected by validation
463                // This branch should never be reached
464                return Err(Error::invalid_input(
465                    "OVERWRITE mode cannot register new bases. This should have been caught by validation.",
466                ));
467            }
468        }
469
470        // Get the schema and the final fragment list
471        let schema = match self.operation {
472            Operation::Overwrite { ref schema, .. } => schema.clone(),
473            Operation::Merge { ref schema, .. } => schema.clone(),
474            Operation::Project { ref schema, .. } => schema.clone(),
475            _ => {
476                if let Some(current_manifest) = current_manifest {
477                    current_manifest.schema.clone()
478                } else {
479                    return Err(Error::internal(
480                        "Cannot create a new dataset without a schema".to_string(),
481                    ));
482                }
483            }
484        };
485
486        // Fragment ids are a high water mark for the whole dataset history: an id
487        // must never name two different sets of rows, or per-fragment state keyed
488        // by id (caches, deletion files, row addresses) can be attributed to the
489        // wrong rows.
490        let mut fragment_id = current_manifest
491            .and_then(|m| m.max_fragment_id())
492            .map(|id| id + 1)
493            .unwrap_or(0);
494        let mut final_fragments = Vec::new();
495        let mut final_indices = current_indices;
496
497        // Snapshot taken before the operation rewrites the list, so coverage can
498        // be compared against what each logical index looked like going in. Only
499        // tables with a MemWAL index maintain coverage, so every other commit --
500        // and the segment clones this costs -- pays nothing.
501        let mem_wal_segments_before = final_indices
502            .iter()
503            .any(|idx| idx.name == MEM_WAL_INDEX_NAME)
504            .then(|| Self::logical_index_segments(&final_indices));
505
506        let mut next_row_id = {
507            // Only use row ids if the feature flag is set already, or this is
508            // a migration activation that explicitly provides the next_row_id.
509            match (current_manifest, config.use_stable_row_ids) {
510                (Some(manifest), _) if manifest.reader_feature_flags & FLAG_STABLE_ROW_IDS != 0 => {
511                    Some(manifest.next_row_id)
512                }
513                (None, true) => Some(0),
514                (_, false) => None,
515                (Some(_), true) => {
516                    // Migration activation: use the provided next_row_id.
517                    if let Some(migration_nri) = config.migration_next_row_id {
518                        Some(migration_nri)
519                    } else {
520                        return Err(Error::not_supported_source(
521                            "This dataset was not created with the stable row ids feature.  Please run `migrate_to_stable_row_ids` before attempting to use stable row ids".into(),
522                        ));
523                    }
524                }
525            }
526        };
527
528        let maybe_existing_fragments =
529            current_manifest
530                .map(|m| m.fragments.as_ref())
531                .ok_or_else(|| {
532                    Error::internal(format!(
533                        "No current manifest was provided while building manifest for operation {}",
534                        self.operation.name()
535                    ))
536                });
537
538        let new_version = current_manifest.map_or(1, |m| m.version + 1);
539
540        match &self.operation {
541            Operation::Clone { .. } => {
542                return Err(Error::internal(
543                    "Clone operation should not enter build_manifest.".to_string(),
544                ));
545            }
546            Operation::Append { fragments } => {
547                final_fragments.extend(maybe_existing_fragments?.clone());
548                let mut new_fragments =
549                    Self::fragments_with_ids(fragments.clone(), &mut fragment_id)
550                        .collect::<Vec<_>>();
551                if let Some(next_row_id) = &mut next_row_id {
552                    Self::assign_row_ids(next_row_id, new_fragments.as_mut_slice())?;
553                    // Add version metadata for all new fragments
554                    for fragment in new_fragments.iter_mut() {
555                        let version_meta = build_version_meta(fragment, new_version);
556                        fragment.last_updated_at_version_meta = version_meta.clone();
557                        fragment.created_at_version_meta = version_meta;
558                    }
559                }
560                final_fragments.extend(new_fragments);
561            }
562            Operation::Delete {
563                updated_fragments,
564                deleted_fragment_ids,
565                ..
566            } => {
567                // Remove the deleted fragments
568                // Hash lookups keep this linear on tables with many fragments.
569                let deleted_ids: HashSet<u64> = deleted_fragment_ids.iter().copied().collect();
570                let updated_by_id: HashMap<u64, &Fragment> =
571                    updated_fragments.iter().map(|f| (f.id, f)).collect();
572                final_fragments.extend(maybe_existing_fragments?.clone());
573                final_fragments.retain(|f| !deleted_ids.contains(&f.id));
574                final_fragments.iter_mut().for_each(|f| {
575                    if let Some(updated) = updated_by_id.get(&f.id) {
576                        *f = (*updated).clone();
577                    }
578                });
579                Self::retain_relevant_indices(&mut final_indices, &schema, &final_fragments)
580            }
581            Operation::Update {
582                removed_fragment_ids,
583                updated_fragments,
584                new_fragments,
585                fields_modified,
586                compacted_sstables,
587                fields_for_preserving_frag_bitmap,
588                update_mode,
589                updated_fragment_offsets,
590                ..
591            } => {
592                // Extract existing fragments once for reuse
593                let existing_fragments = maybe_existing_fragments?;
594
595                // Apply updates to existing fragments
596                // Hash lookups keep this linear on tables with many fragments.
597                let removed_ids: HashSet<u64> = removed_fragment_ids.iter().copied().collect();
598                let mut updated_by_id: HashMap<u64, &Fragment> =
599                    HashMap::with_capacity(updated_fragments.len());
600                for fragment in updated_fragments {
601                    updated_by_id.entry(fragment.id).or_insert(fragment);
602                }
603                let updated_frags: Vec<Fragment> = existing_fragments
604                    .iter()
605                    .filter_map(|f| {
606                        if removed_ids.contains(&f.id) {
607                            return None;
608                        }
609                        if let Some(&updated) = updated_by_id.get(&f.id) {
610                            let mut updated = updated.clone();
611                            // Carry forward the fragment's current overlays (which
612                            // may include ones added by a concurrent commit). An
613                            // in-place column rewrite then tombstones the overlaid
614                            // fields it rewrote, since the fresh base values
615                            // supersede them.
616                            updated.overlays = f.overlays.clone();
617                            if matches!(update_mode, Some(RewriteColumns)) {
618                                crate::format::overlay::tombstone_overlay_fields(
619                                    &mut updated.overlays,
620                                    fields_modified,
621                                );
622                            }
623                            Some(updated)
624                        } else {
625                            Some(f.clone())
626                        }
627                    })
628                    .collect();
629
630                // Update version metadata for updated fragments if stable row IDs are enabled
631                // Note: We don't update version metadata for fragments with deletion vectors
632                // because the version sequences are indexed by physical row position, not logical position.
633                // Version metadata for deleted rows will be filtered out during scan using the deletion vector.
634                if next_row_id.is_some() {
635                    // Version metadata will be properly set during compaction when deletions are materialized
636                }
637
638                final_fragments.extend(updated_frags);
639
640                if next_row_id.is_some()
641                    && matches!(update_mode, Some(RewriteColumns))
642                    && let Some(UpdatedFragmentOffsets(off_map)) = updated_fragment_offsets
643                    && !off_map.is_empty()
644                {
645                    let prev_version = current_manifest.map(|m| m.version).unwrap_or(0);
646                    for fragment in final_fragments.iter_mut() {
647                        let Some(bitmap) = off_map.get(&fragment.id) else {
648                            continue;
649                        };
650                        // Defense-in-depth: only stamp fragments that were actually
651                        // rewritten. validate_operation enforces this invariant before
652                        // build_manifest is called; this guard catches any path that
653                        // bypasses validation.
654                        if !updated_by_id.contains_key(&fragment.id) {
655                            continue;
656                        }
657                        if bitmap.is_empty() {
658                            continue;
659                        }
660                        // Skip fragments with no existing version metadata: the helper
661                        // would fill unmatched rows with prev_version, fabricating a
662                        // last_updated stamp for rows that never had one.
663                        if fragment.last_updated_at_version_meta.is_none() {
664                            continue;
665                        }
666                        let max_allowed = existing_fragments
667                            .iter()
668                            .find(|f| f.id == fragment.id)
669                            .and_then(|f| f.physical_rows)
670                            .unwrap_or(1 << 24);
671                        if bitmap.len() as usize > max_allowed {
672                            return Err(Error::invalid_input(format!(
673                                "updatedFragmentOffsets cardinality {} exceeds fragment {} limit {}",
674                                bitmap.len(),
675                                fragment.id,
676                                max_allowed
677                            )));
678                        }
679                        if let Some(max_off) = bitmap.max()
680                            && max_off as usize >= max_allowed
681                        {
682                            return Err(Error::invalid_input(format!(
683                                "updatedFragmentOffsets max offset {} exceeds fragment {} limit {}",
684                                max_off, fragment.id, max_allowed
685                            )));
686                        }
687                        let offsets: Vec<usize> = bitmap.iter().map(|o| o as usize).collect();
688                        crate::rowids::version::refresh_row_latest_update_meta_for_partial_frag_rewrite_cols(
689                            fragment,
690                            &offsets,
691                            new_version,
692                            prev_version,
693                        )?;
694                    }
695                }
696
697                // If we updated any fields, remove those fragments from indices covering those fields
698                Self::prune_updated_fields_from_indices(
699                    &mut final_indices,
700                    updated_fragments,
701                    fields_modified,
702                );
703
704                let mut new_fragments =
705                    Self::fragments_with_ids(new_fragments.clone(), &mut fragment_id)
706                        .collect::<Vec<_>>();
707
708                // Assign row IDs to any fragments that don't have them yet
709                // (e.g., inserted rows from merge_insert operations)
710                if let Some(next_row_id) = &mut next_row_id {
711                    Self::assign_row_ids(next_row_id, new_fragments.as_mut_slice())?;
712                }
713
714                if next_row_id.is_some() {
715                    resolve_update_version_metadata(
716                        existing_fragments,
717                        new_fragments.as_mut_slice(),
718                        new_version,
719                    )?;
720                }
721
722                if config.use_stable_row_ids
723                    && update_mode.is_some()
724                    && *update_mode == Some(RewriteRows)
725                {
726                    let pure_updated_frag_ids =
727                        Self::collect_pure_rewrite_row_update_frags_ids(&new_fragments)?;
728
729                    // collect all the original frag ids that contains the updated rows
730                    let original_fragment_ids: Vec<u64> = removed_fragment_ids
731                        .iter()
732                        .chain(updated_fragments.iter().map(|f| &f.id))
733                        .copied()
734                        .collect();
735
736                    // The original fragments that carried an overlay: their moved rows may have a
737                    // stale index entry (see `register_pure_rewrite_rows_update_frags_in_indices`).
738                    // Reuse the hash lookups built above instead of scanning
739                    // `original_fragment_ids` per fragment.
740                    let original_overlaid_frags: HashMap<u32, &Fragment> = existing_fragments
741                        .iter()
742                        .filter(|f| {
743                            (removed_ids.contains(&f.id) || updated_by_id.contains_key(&f.id))
744                                && !f.overlays.is_empty()
745                        })
746                        .map(|f| (f.id as u32, f))
747                        .collect();
748
749                    Self::register_pure_rewrite_rows_update_frags_in_indices(
750                        &mut final_indices,
751                        &pure_updated_frag_ids,
752                        &original_fragment_ids,
753                        fields_for_preserving_frag_bitmap,
754                        &original_overlaid_frags,
755                        &schema,
756                    )?;
757                }
758
759                if let Some(next_row_id) = &mut next_row_id {
760                    Self::assign_row_ids(next_row_id, new_fragments.as_mut_slice())?;
761                    // Note: Version metadata is already set above (lines 1627-1755)
762                    // for Update operations, preserving created_at from original fragments.
763                    // Don't overwrite it here.
764                }
765                // Identify fragments that were updated or newly created in this update
766                let mut target_ids: HashSet<u64> = HashSet::new();
767                target_ids.extend(new_fragments.iter().map(|f| f.id));
768                final_fragments.extend(new_fragments);
769                Self::retain_relevant_indices(&mut final_indices, &schema, &final_fragments);
770
771                if !compacted_sstables.is_empty() {
772                    update_mem_wal_index_compacted_sstables(
773                        &mut final_indices,
774                        new_version,
775                        compacted_sstables.clone(),
776                    )?;
777                }
778            }
779            Operation::Overwrite { fragments, .. } => {
780                // Every fragment in an overwrite is newly written, so all of them
781                // take fresh ids regardless of the id they arrive with. Fragments
782                // carried over from the dataset being replaced are rejected by
783                // `validate_operation`, which is what makes ignoring the incoming
784                // id safe here.
785                let mut new_fragments = fragments.clone();
786                for fragment in new_fragments.iter_mut() {
787                    fragment.id = fragment_id;
788                    fragment_id += 1;
789                }
790                if let Some(next_row_id) = &mut next_row_id {
791                    Self::assign_row_ids(next_row_id, new_fragments.as_mut_slice())?;
792                    // Add version metadata for all new fragments
793                    for fragment in new_fragments.iter_mut() {
794                        let version_meta = build_version_meta(fragment, new_version);
795                        fragment.last_updated_at_version_meta = version_meta.clone();
796                        fragment.created_at_version_meta = version_meta;
797                    }
798                }
799                final_fragments.extend(new_fragments);
800                final_indices = Vec::new();
801            }
802            Operation::Rewrite {
803                groups,
804                rewritten_indices,
805                frag_reuse_index,
806            } => {
807                final_fragments.extend(maybe_existing_fragments?.clone());
808                let current_version = current_manifest.map(|m| m.version).unwrap_or_default();
809                Self::handle_rewrite_fragments(
810                    &mut final_fragments,
811                    groups,
812                    &mut fragment_id,
813                    current_version,
814                    next_row_id.as_ref(),
815                )?;
816
817                if next_row_id.is_some() {
818                    // We can re-use indices, but need to rewrite the fragment bitmaps
819                    debug_assert!(rewritten_indices.is_empty());
820                    for index in final_indices.iter_mut() {
821                        let results_are_row_addrs = index.results_are_row_addrs();
822                        if let Some(fragment_bitmap) = &mut index.fragment_bitmap {
823                            *fragment_bitmap = if results_are_row_addrs {
824                                // Stable row ids survive a rewrite, so a row-id-domain index
825                                // can simply follow its data to the new fragments. An
826                                // address-domain index cannot: its stored addresses point into
827                                // the fragments the rewrite dropped. Claiming coverage of the
828                                // new fragments would make it answer queries with addresses
829                                // that no longer resolve, so drop the rewritten fragments from
830                                // its coverage instead and let the scanner fall back to a full
831                                // scan for them.
832                                Self::drop_rewritten_fragments(fragment_bitmap, groups)
833                            } else {
834                                Self::recalculate_fragment_bitmap(fragment_bitmap, groups)?
835                            };
836                        }
837                    }
838                } else {
839                    Self::handle_rewrite_indices(&mut final_indices, rewritten_indices, groups)?;
840                }
841
842                // A full compaction materializes a fragment's overlays into fresh
843                // base data. Any index older than one of those overlays was built on
844                // the pre-overlay values, so drop the rewritten fragment from its
845                // coverage to keep it from serving stale values.
846                Self::prune_overlay_stale_fields_from_indices(&mut final_indices, groups);
847
848                if let Some(frag_reuse_index) = frag_reuse_index {
849                    final_indices.retain(|idx| idx.name != frag_reuse_index.name);
850                    final_indices.push(frag_reuse_index.clone());
851                }
852            }
853            Operation::CreateIndex {
854                new_indices,
855                removed_indices,
856                ..
857            } => {
858                final_fragments.extend(maybe_existing_fragments?.clone());
859                let removed_uuids = removed_indices
860                    .iter()
861                    .map(|old_index| old_index.uuid)
862                    .collect::<HashSet<_>>();
863                let new_uuids = new_indices
864                    .iter()
865                    .map(|new_index| new_index.uuid)
866                    .collect::<HashSet<_>>();
867                final_indices.retain(|existing_index| {
868                    !removed_uuids.contains(&existing_index.uuid)
869                        && !new_uuids.contains(&existing_index.uuid)
870                });
871                for new_index in new_indices {
872                    new_index.validate_covering_fields()?;
873                }
874                final_indices.extend(new_indices.clone());
875            }
876            Operation::ReserveFragments { .. } | Operation::UpdateConfig { .. } => {
877                final_fragments.extend(maybe_existing_fragments?.clone());
878            }
879            Operation::Merge { fragments, .. } => {
880                let existing_fragments = maybe_existing_fragments?;
881                let mut merged_fragments = fragments.clone();
882                if next_row_id.is_some() {
883                    let prev_by_id: HashMap<u64, &Fragment> =
884                        existing_fragments.iter().map(|f| (f.id, f)).collect();
885                    for fragment in merged_fragments.iter_mut() {
886                        match prev_by_id.get(&fragment.id) {
887                            Some(prev) => {
888                                if merge_fragment_physically_rewritten(prev, fragment) {
889                                    crate::rowids::version::refresh_row_latest_update_meta_for_full_frag_rewrite_cols(
890                                        fragment,
891                                        new_version,
892                                    )?;
893                                }
894                            }
895                            None => {
896                                // Brand-new fragment ID not present in the previous manifest.
897                                // Set both last_updated and created version meta, consistent
898                                // with Append/Overwrite for genuinely new fragments.
899                                crate::rowids::version::refresh_row_latest_update_meta_for_full_frag_rewrite_cols(
900                                    fragment,
901                                    new_version,
902                                )?;
903                                fragment.created_at_version_meta =
904                                    fragment.last_updated_at_version_meta.clone();
905                            }
906                        }
907                    }
908                }
909                final_fragments.extend(merged_fragments);
910
911                // A Merge can rewrite a column's data file in place; the field stays
912                // in the schema, so the index is retained -- prune its now-stale
913                // entries for the rewritten fragments.
914                Self::prune_merge_rewritten_fields_from_indices(
915                    &mut final_indices,
916                    existing_fragments,
917                    fragments,
918                );
919
920                // Some fields that have indices may have been removed, so we should
921                // remove those indices as well.
922                Self::retain_relevant_indices(&mut final_indices, &schema, &final_fragments)
923            }
924            Operation::Project { .. } => {
925                final_fragments.extend(maybe_existing_fragments?.clone());
926
927                // We might have removed all fields for certain data files, so
928                // we should remove the data files that are no longer relevant.
929                let remaining_field_ids = schema
930                    .fields_pre_order()
931                    .map(|f| f.id)
932                    .collect::<HashSet<_>>();
933                for fragment in final_fragments.iter_mut() {
934                    fragment.files.retain(|file| {
935                        file.fields
936                            .iter()
937                            .any(|field_id| remaining_field_ids.contains(field_id))
938                    });
939                }
940
941                // Some fields that have indices may have been removed, so we should
942                // remove those indices as well.
943                Self::retain_relevant_indices(&mut final_indices, &schema, &final_fragments)
944            }
945            Operation::Restore { .. } => {
946                unreachable!()
947            }
948            Operation::DataReplacement { replacements } => {
949                log::warn!(
950                    "Building manifest with DataReplacement operation. This operation is not stable yet, please use with caution."
951                );
952
953                let (old_fragment_ids, new_datafiles): (Vec<&u64>, Vec<&DataFile>) = replacements
954                    .iter()
955                    .map(|DataReplacementGroup(fragment_id, new_file)| (fragment_id, new_file))
956                    .unzip();
957
958                // 1. make sure the new files all have the same fields / or empty
959                // NOTE: arguably this requirement could be relaxed in the future
960                // for the sake of simplicity, we require the new files to have the same fields
961                if new_datafiles
962                    .iter()
963                    .map(|f| f.fields.clone())
964                    .collect::<HashSet<_>>()
965                    .len()
966                    > 1
967                {
968                    let field_info = new_datafiles
969                        .iter()
970                        .enumerate()
971                        .map(|(id, f)| (id, f.fields.clone()))
972                        .fold("".to_string(), |acc, (id, fields)| {
973                            format!("{}File {}: {:?}\n", acc, id, fields)
974                        });
975
976                    return Err(Error::invalid_input(format!(
977                        "All new data files must have the same fields, but found different fields:\n{field_info}"
978                    )));
979                }
980
981                let existing_fragments = maybe_existing_fragments?;
982
983                // Collect replaced field IDs before consuming new_datafiles
984                let replaced_fields: Vec<u32> = new_datafiles
985                    .first()
986                    .map(|f| {
987                        f.schema(&schema)
988                            .field_ids()
989                            .iter()
990                            .chain(f.fields.iter())
991                            .filter(|&&id| id >= 0)
992                            .map(|&id| id as u32)
993                            .collect()
994                    })
995                    .unwrap_or_default();
996
997                // 2. check that the fragments being modified have isomorphic layouts along the columns being replaced
998                // 3. add modified fragments to final_fragments
999                for (frag_id, new_file) in old_fragment_ids.iter().zip(new_datafiles) {
1000                    let frag = existing_fragments
1001                        .iter()
1002                        .find(|f| f.id == **frag_id)
1003                        .ok_or_else(|| {
1004                            Error::invalid_input(
1005                                "Fragment being replaced not found in existing fragments",
1006                            )
1007                        })?;
1008                    let mut new_frag = frag.clone();
1009
1010                    // Physical mappings differ across V2 encodings (a nested
1011                    // parent may have no column of its own). Replacement and
1012                    // tombstoning operate on the logical field coverage.
1013                    let replacement_ids = new_file
1014                        .schema(&schema)
1015                        .field_ids()
1016                        .into_iter()
1017                        .chain(new_file.fields.iter().copied())
1018                        .collect::<HashSet<_>>();
1019
1020                    // TODO(rmeng): check new file and fragment are the same length
1021
1022                    let mut columns_covered = HashSet::new();
1023                    // Set when an existing file covers exactly the replaced
1024                    // fields, so the whole file swaps rather than part of it.
1025                    let mut replaced_in_place = false;
1026                    for file in &mut new_frag.files {
1027                        if file.fields == new_file.fields
1028                            && file.file_major_version == new_file.file_major_version
1029                            && file.file_minor_version == new_file.file_minor_version
1030                        {
1031                            // assign the new file path / size / base to the fragment
1032                            file.path = new_file.path.clone();
1033                            file.file_size_bytes = new_file.file_size_bytes.clone();
1034                            file.base_id = new_file.base_id;
1035                            replaced_in_place = true;
1036                        }
1037                        columns_covered.extend(file.schema(&schema).field_ids());
1038                        columns_covered.extend(file.fields.iter().copied());
1039                    }
1040                    // Reject a file whose version does not decode before any
1041                    // arm publishes it.
1042                    new_file.file_version()?;
1043
1044                    // SPECIAL CASE: if the column(s) being replaced are not covered by the fragment
1045                    // Then it means it's a all-NULL column that is being replaced with real data
1046                    // just add it to the final fragments. Push the DataFile as
1047                    // given so every field (including base_id) is preserved.
1048                    if columns_covered.is_disjoint(&replacement_ids) {
1049                        new_frag.files.push(new_file.clone());
1050                    } else if !replaced_in_place
1051                        && replacement_ids.is_subset(&columns_covered)
1052                        && new_frag.files.iter().all(|file| {
1053                            file.file_version()
1054                                .is_ok_and(|version| version != ConcreteFileVersion::V1)
1055                                || file
1056                                    .fields
1057                                    .iter()
1058                                    .all(|field| !replacement_ids.contains(field))
1059                        })
1060                    {
1061                        // Tombstone the replaced fields where they live and
1062                        // append the new file to answer for them, the idiom
1063                        // `update_columns` uses. Compaction decides that layout,
1064                        // so the fields may sit in one wider file or span
1065                        // several.
1066                        //
1067                        // Legacy V1 is excluded: its reader derives the page table
1068                        // offset from the first field in the metadata, so
1069                        // tombstoning one field leaves its siblings decoding from
1070                        // the wrong pages. A field a V1 file covers keeps
1071                        // exact-match replacement.
1072                        for file in &mut new_frag.files {
1073                            // Same reason as the guard above.
1074                            if file.file_version()? == ConcreteFileVersion::V1 {
1075                                continue;
1076                            }
1077                            file.fields = file
1078                                .fields
1079                                .iter()
1080                                .map(|field| {
1081                                    if replacement_ids.contains(field) {
1082                                        TOMBSTONE_FIELD_ID
1083                                    } else {
1084                                        *field
1085                                    }
1086                                })
1087                                .collect::<Vec<_>>()
1088                                .into();
1089                        }
1090                        // Every data file must share at least one field with
1091                        // the dataset schema: a file kept alive only by
1092                        // tombstones or by ids the schema no longer defines is
1093                        // unreachable to readers, uncollectable by cleanup,
1094                        // and reported corrupt by validate().
1095                        let live_ids = schema
1096                            .fields_pre_order()
1097                            .map(|field| field.id)
1098                            .collect::<HashSet<i32>>();
1099                        new_frag
1100                            .files
1101                            .retain(|file| file.fields.iter().any(|f| live_ids.contains(f)));
1102                        new_frag.files.push(new_file.clone());
1103                    }
1104
1105                    // Nothing changed in the current fragment, which is not expected -- error out
1106                    if &new_frag == frag {
1107                        return Err(Error::invalid_input(
1108                            "Expected to modify the fragment but no changes were made. This means the new data files does not align with any exiting datafiles. Please check if the schema of the new data files matches the schema of the old data files including the file major and minor versions",
1109                        ));
1110                    }
1111
1112                    // New base values supersede any overlay still shadowing
1113                    // them, so tombstone the overlaid fields. An overlay
1114                    // committed after this transaction's snapshot is the newer
1115                    // value though -- the conflict resolver rebases these two
1116                    // precisely because the overlay wins -- so it stays, and
1117                    // being newer it stays last, preserving the ordering.
1118                    let (mut superseded, newer): (Vec<_>, Vec<_>) = new_frag
1119                        .overlays
1120                        .drain(..)
1121                        .partition(|overlay| overlay.committed_version <= self.read_version);
1122                    crate::format::overlay::tombstone_overlay_fields(
1123                        &mut superseded,
1124                        &replaced_fields,
1125                    );
1126                    superseded.extend(newer);
1127                    new_frag.overlays = superseded;
1128
1129                    final_fragments.push(new_frag);
1130                }
1131
1132                let fragments_changed = old_fragment_ids
1133                    .iter()
1134                    .cloned()
1135                    .cloned()
1136                    .collect::<HashSet<_>>();
1137
1138                // 4. push fragments that didn't change back to final_fragments
1139                let unmodified_fragments = existing_fragments
1140                    .iter()
1141                    .filter(|f| !fragments_changed.contains(&f.id))
1142                    .cloned()
1143                    .collect::<Vec<_>>();
1144
1145                final_fragments.extend(unmodified_fragments);
1146
1147                // 5. Invalidate index bitmaps for replaced fields
1148                let modified_fragments: Vec<Fragment> = final_fragments
1149                    .iter()
1150                    .filter(|f| fragments_changed.contains(&f.id))
1151                    .cloned()
1152                    .collect();
1153
1154                // A replacement changes what its rows read as, so stamp them
1155                // updated. Without this, get_updated_rows never reports them and
1156                // an incremental consumer skips them for good.
1157                if next_row_id.is_some() {
1158                    let new_version = current_manifest.map_or(1, |m| m.version + 1);
1159                    for fragment in final_fragments
1160                        .iter_mut()
1161                        .filter(|f| fragments_changed.contains(&f.id))
1162                    {
1163                        crate::rowids::version::refresh_row_latest_update_meta_for_full_frag_rewrite_cols(
1164                            fragment,
1165                            new_version,
1166                        )?;
1167                    }
1168                }
1169
1170                Self::prune_updated_fields_from_indices(
1171                    &mut final_indices,
1172                    &modified_fragments,
1173                    &replaced_fields,
1174                );
1175            }
1176            Operation::DataOverlay { groups } => {
1177                // Stamp each overlay with the version this commit is producing.
1178                // build_manifest re-runs on every retry with an updated
1179                // current_manifest, so this is naturally re-stamped on retry.
1180                let new_version = current_manifest.map_or(1, |m| m.version + 1);
1181
1182                let existing_fragments = maybe_existing_fragments?;
1183                // Multiple groups may target the same fragment; merge them in
1184                // order rather than letting a HashMap collapse drop all but the
1185                // last group's overlays.
1186                let mut overlays_by_fragment: HashMap<u64, Vec<&DataOverlayFile>> = HashMap::new();
1187                for group in groups {
1188                    overlays_by_fragment
1189                        .entry(group.fragment_id)
1190                        .or_default()
1191                        .extend(group.overlays.iter());
1192                }
1193
1194                // Every group must target an existing fragment. Build a set of
1195                // existing ids once so this is O(groups + fragments) rather than
1196                // O(groups * fragments).
1197                let existing_fragment_ids: HashSet<u64> =
1198                    existing_fragments.iter().map(|f| f.id).collect();
1199                for fragment_id in overlays_by_fragment.keys() {
1200                    if !existing_fragment_ids.contains(fragment_id) {
1201                        return Err(Error::invalid_input(format!(
1202                            "DataOverlay targets fragment {fragment_id}, which does not exist"
1203                        )));
1204                    }
1205                }
1206
1207                for fragment in existing_fragments {
1208                    let mut fragment = fragment.clone();
1209                    if let Some(new_overlays) = overlays_by_fragment.get(&fragment.id) {
1210                        if next_row_id.is_some() {
1211                            let mut covered_offsets = RoaringBitmap::new();
1212                            for overlay in new_overlays {
1213                                match &overlay.coverage {
1214                                    OverlayCoverage::Shared(bitmap) => {
1215                                        covered_offsets |= bitmap.as_ref();
1216                                    }
1217                                    OverlayCoverage::PerField(bitmaps) => {
1218                                        for bitmap in bitmaps {
1219                                            covered_offsets |= bitmap.as_ref();
1220                                        }
1221                                    }
1222                                }
1223                            }
1224                            if !covered_offsets.is_empty() {
1225                                let covered_offsets: Vec<usize> = covered_offsets
1226                                    .iter()
1227                                    .map(|offset| offset as usize)
1228                                    .collect();
1229                                // Scans interpret missing lineage metadata as version 1,
1230                                // so using 1 as the fallback preserves the observable
1231                                // version of every uncovered row.
1232                                crate::rowids::version::refresh_row_latest_update_meta_for_partial_frag_rewrite_cols(
1233                                    &mut fragment,
1234                                    &covered_offsets,
1235                                    new_version,
1236                                    1,
1237                                )?;
1238                            }
1239                        }
1240                        // Appended (not replaced) so concurrently-written overlays
1241                        // survive; later entries are newer.
1242                        fragment
1243                            .overlays
1244                            .extend(new_overlays.iter().map(|&overlay| {
1245                                let mut overlay = overlay.clone();
1246                                overlay.committed_version = new_version;
1247                                overlay
1248                            }));
1249                    }
1250                    final_fragments.push(fragment);
1251                }
1252            }
1253            Operation::UpdateMemWalState {
1254                compacted_sstables, ..
1255            } => {
1256                // Updates the MemWAL index only; the fragments are unchanged.
1257                final_fragments.extend(maybe_existing_fragments?.clone());
1258                update_mem_wal_index_compacted_sstables(
1259                    &mut final_indices,
1260                    new_version,
1261                    compacted_sstables.clone(),
1262                )?;
1263            }
1264            Operation::UpdateBases { .. } => {
1265                // UpdateBases operation doesn't modify fragments or indices
1266                // Base paths are handled in the manifest creation section below
1267                final_fragments.extend(maybe_existing_fragments?.clone());
1268            }
1269        };
1270
1271        // If a fragment was reserved then it may not belong at the end of the fragments list.
1272        final_fragments.sort_by_key(|frag| frag.id);
1273
1274        // Clean up data files that only contain tombstoned fields
1275        Self::remove_tombstoned_data_files(&mut final_fragments);
1276
1277        // Enforce the newest-last overlay ordering invariant at the write
1278        // boundary. Load normalizes with a sort; this rejects any commit path
1279        // that assembled a fragment's overlays out of order.
1280        for fragment in &final_fragments {
1281            if !fragment.overlays.is_empty() {
1282                crate::format::overlay::verify_overlays_newest_last(&fragment.overlays)?;
1283            }
1284        }
1285
1286        let user_requested_version = match (&config.storage_format, config.use_legacy_format) {
1287            (Some(storage_format), _) => Some(storage_format.lance_file_format()),
1288            (None, Some(true)) => Some(ConcreteFileVersion::V1),
1289            (None, Some(false)) => Some(ConcreteFileVersion::V2_0),
1290            (None, None) => None,
1291        };
1292
1293        // Applied once the final index list is known, so it sees exactly the
1294        // indices this commit publishes rather than what any one operation arm
1295        // intended.
1296        if let Some(segments_before) = mem_wal_segments_before.as_ref() {
1297            Self::apply_mem_wal_index_coverage(
1298                &mut final_indices,
1299                segments_before,
1300                read_version_state,
1301                new_version,
1302            )?;
1303        }
1304
1305        let mut manifest = if let Some(current_manifest) = current_manifest {
1306            // OVERWRITE with initial_bases on existing dataset is not allowed (caught by validation)
1307            // So we always use new_from_previous which preserves base_paths
1308            let mut prev_manifest =
1309                Manifest::new_from_previous(current_manifest, schema, Arc::new(final_fragments));
1310
1311            if let (Some(user_requested_version), Operation::Overwrite { .. }) =
1312                (user_requested_version, &self.operation)
1313            {
1314                // If this is an overwrite operation and the user has requested a specific version
1315                // then overwrite with that version.  Otherwise, if the user didn't request a specific
1316                // version, then overwrite with whatever version we had before.
1317                prev_manifest.data_storage_format = DataStorageFormat::new(user_requested_version);
1318            }
1319
1320            prev_manifest
1321        } else {
1322            let data_storage_format =
1323                Self::data_storage_format_from_files(&final_fragments, user_requested_version)?;
1324            Manifest::new(
1325                schema,
1326                Arc::new(final_fragments),
1327                data_storage_format,
1328                reference_paths,
1329            )
1330        };
1331
1332        manifest.tag.clone_from(&self.tag);
1333
1334        if config.auto_set_feature_flags {
1335            // Internal operations (e.g. CreateIndex) build with the default config,
1336            // which has use_stable_row_ids = false. Without inheriting from the previous
1337            // manifest, apply_feature_flags would clear FLAG_STABLE_ROW_IDS.
1338            let inherited = current_manifest
1339                .map(|m| m.uses_stable_row_ids())
1340                .unwrap_or(false);
1341            let use_stable_row_ids = config.use_stable_row_ids || inherited;
1342            apply_feature_flags(
1343                &mut manifest,
1344                use_stable_row_ids,
1345                config.disable_transaction_file,
1346            )?;
1347        }
1348        // Set after apply_feature_flags, which resets both flag words -- and a
1349        // `Manifest` only points at its index section, so the flag cannot be
1350        // derived there.
1351        //
1352        // Derived fresh from `final_indices` on every commit, never inherited.
1353        // Every manifest this reaches starts without the covering bit, so there
1354        // is no stale bit to clear. Dropping the last covering index lifts the
1355        // fence by simply not setting it again. Inheriting it from the previous
1356        // manifest instead would make the fence permanent.
1357        //
1358        // Both words: a reader that selects a vector index by membership of
1359        // `fields` would answer a query on a merely-carried column with an index
1360        // keyed on another one, and a writer that treats every entry of `fields`
1361        // as keyed would mismaintain it.
1362        if final_indices
1363            .iter()
1364            .any(|index| !index.covering_fields.is_empty())
1365        {
1366            manifest.reader_feature_flags |= FLAG_COVERED_INDEX_METADATA;
1367            manifest.writer_feature_flags |= FLAG_COVERED_INDEX_METADATA;
1368        }
1369
1370        if let Some(current_manifest) = current_manifest {
1371            inherit_sticky_feature_flags(&mut manifest, current_manifest)?;
1372        }
1373
1374        manifest.set_timestamp(config.timestamp_nanos);
1375
1376        manifest.update_max_fragment_id();
1377
1378        match &self.operation {
1379            Operation::Overwrite {
1380                config_upsert_values: Some(tm),
1381                ..
1382            } => {
1383                manifest.config_mut().extend(tm.clone());
1384            }
1385            Operation::UpdateConfig {
1386                config_updates,
1387                table_metadata_updates,
1388                schema_metadata_updates,
1389                field_metadata_updates,
1390            } => {
1391                if let Some(config_updates) = config_updates {
1392                    let mut config = manifest.config.clone();
1393                    apply_update_map(&mut config, config_updates);
1394                    manifest.config = config;
1395                }
1396                if let Some(table_metadata_updates) = table_metadata_updates {
1397                    let mut table_metadata = manifest.table_metadata.clone();
1398                    apply_update_map(&mut table_metadata, table_metadata_updates);
1399                    manifest.table_metadata = table_metadata;
1400                }
1401                if let Some(schema_metadata_updates) = schema_metadata_updates {
1402                    let mut schema_metadata = manifest.schema.metadata.clone();
1403                    apply_update_map(&mut schema_metadata, schema_metadata_updates);
1404                    manifest.schema.metadata = schema_metadata;
1405                }
1406                // The unenforced primary and clustering keys are reserved
1407                // schema properties: each is immutable once set, and its
1408                // reserved metadata keys cannot be written with an invalid
1409                // value. Capture the prior keys, and whether this transaction
1410                // writes a reserved key, before applying the updates so
1411                // violations can be rejected below. This runs on every apply,
1412                // including conflict-rebase, so it also rejects the
1413                // concurrent-writer race.
1414                let primary_key_before: Vec<i32> = manifest
1415                    .schema
1416                    .unenforced_primary_key()
1417                    .iter()
1418                    .map(|field| field.id)
1419                    .collect();
1420                let writes_primary_key = field_metadata_updates.values().any(|update| {
1421                    update.update_entries.iter().any(|entry| {
1422                        entry.key == LANCE_UNENFORCED_PRIMARY_KEY
1423                            || entry.key == LANCE_UNENFORCED_PRIMARY_KEY_POSITION
1424                    })
1425                });
1426                let clustering_key_before: Vec<i32> = manifest
1427                    .schema
1428                    .unenforced_clustering_key()
1429                    .iter()
1430                    .map(|field| field.id)
1431                    .collect();
1432                let writes_clustering_key = field_metadata_updates.values().any(|update| {
1433                    update
1434                        .update_entries
1435                        .iter()
1436                        .any(|entry| entry.key == LANCE_UNENFORCED_CLUSTERING_KEY_POSITION)
1437                });
1438                for (field_id, field_metadata_update) in field_metadata_updates {
1439                    if let Some(field) = manifest.schema.field_by_id_mut(*field_id) {
1440                        apply_update_map(&mut field.metadata, field_metadata_update);
1441                        // Also set unenforced primary key based on updated field metadata.
1442                        field.unenforced_primary_key_position = field
1443                            .metadata
1444                            .get(LANCE_UNENFORCED_PRIMARY_KEY_POSITION)
1445                            .and_then(|s| s.parse::<u32>().ok())
1446                            .or_else(|| {
1447                                field
1448                                    .metadata
1449                                    .get(LANCE_UNENFORCED_PRIMARY_KEY)
1450                                    .filter(|s| str_is_truthy(s))
1451                                    .map(|_| 0)
1452                            });
1453                        // Also set unenforced clustering key based on updated
1454                        // field metadata.
1455                        field.unenforced_clustering_key_position = field
1456                            .metadata
1457                            .get(LANCE_UNENFORCED_CLUSTERING_KEY_POSITION)
1458                            .and_then(|s| s.parse::<u32>().ok());
1459                    } else {
1460                        return Err(Error::invalid_input_source(
1461                            format!("Field with id {} does not exist", field_id).into(),
1462                        ));
1463                    }
1464                }
1465                let primary_key_after: Vec<i32> = manifest
1466                    .schema
1467                    .unenforced_primary_key()
1468                    .iter()
1469                    .map(|field| field.id)
1470                    .collect();
1471                if !primary_key_before.is_empty() {
1472                    // The primary key is already set: reject any change to it,
1473                    // and any write that touches a reserved primary key.
1474                    if writes_primary_key || primary_key_after != primary_key_before {
1475                        return Err(Error::invalid_input(
1476                            "the unenforced primary key is a reserved key and cannot be changed once set",
1477                        ));
1478                    }
1479                } else if writes_primary_key && primary_key_after.is_empty() {
1480                    // A reserved primary key was written but did not install a
1481                    // valid primary key (e.g. a non-marker flag value or a
1482                    // non-numeric position).
1483                    return Err(Error::invalid_input(
1484                        "the unenforced primary key is a reserved key and cannot be set to an invalid value",
1485                    ));
1486                }
1487                if writes_primary_key {
1488                    // Installing by field metadata skips the Arrow-schema
1489                    // conversion that would otherwise validate the key.
1490                    manifest.schema.verify_primary_key()?;
1491                }
1492                let clustering_key_after: Vec<i32> = manifest
1493                    .schema
1494                    .unenforced_clustering_key()
1495                    .iter()
1496                    .map(|field| field.id)
1497                    .collect();
1498                if !clustering_key_before.is_empty() {
1499                    // The clustering key is already set: reject any change to
1500                    // it, and any write that touches the reserved key.
1501                    if writes_clustering_key || clustering_key_after != clustering_key_before {
1502                        return Err(Error::invalid_input(
1503                            "the unenforced clustering key is a reserved key and cannot be changed once set",
1504                        ));
1505                    }
1506                } else if writes_clustering_key && clustering_key_after.is_empty() {
1507                    // The reserved clustering key was written but did not
1508                    // install a valid clustering key (e.g. a non-numeric
1509                    // position value).
1510                    return Err(Error::invalid_input(
1511                        "the unenforced clustering key is a reserved key and cannot be set to an invalid value",
1512                    ));
1513                }
1514            }
1515            _ => {}
1516        }
1517
1518        // Handle UpdateBases operation to update manifest base_paths
1519        if let Operation::UpdateBases { new_bases } = &self.operation {
1520            // Validate and add new base paths to the manifest
1521            for new_base in new_bases {
1522                // Check for conflicts with existing base paths
1523                if let Some(existing_base) = manifest
1524                    .base_paths
1525                    .values()
1526                    .find(|bp| bp.name == new_base.name || bp.path == new_base.path)
1527                {
1528                    return Err(Error::invalid_input(format!(
1529                        "Conflict detected: Base path with name '{:?}' or path '{}' already exists. Existing: name='{:?}', path='{}'",
1530                        new_base.name, new_base.path, existing_base.name, existing_base.path
1531                    )));
1532                }
1533
1534                // Assign a new ID if not already assigned
1535                let mut base_to_add = new_base.clone();
1536                if base_to_add.id == 0 {
1537                    let next_id = manifest
1538                        .base_paths
1539                        .keys()
1540                        .max()
1541                        .map(|&id| id + 1)
1542                        .unwrap_or(1);
1543                    base_to_add.id = next_id;
1544                }
1545
1546                manifest.base_paths.insert(base_to_add.id, base_to_add);
1547            }
1548        }
1549
1550        if let Operation::ReserveFragments { num_fragments } = self.operation {
1551            manifest.max_fragment_id = Some(manifest.max_fragment_id.unwrap_or(0) + num_fragments);
1552        }
1553
1554        manifest.transaction_file = Some(transaction_file_path.to_string());
1555
1556        if let Some(next_row_id) = next_row_id {
1557            manifest.next_row_id = next_row_id;
1558        }
1559
1560        Ok((manifest, final_indices))
1561    }
1562
1563    /// Remove data files that only contain tombstoned fields (-2)
1564    /// These files no longer contain any live data and can be safely dropped
1565    fn remove_tombstoned_data_files(fragments: &mut [Fragment]) {
1566        for fragment in fragments {
1567            fragment.files.retain(|file| {
1568                // Keep file if it has at least one non-tombstoned field
1569                file.fields.iter().any(|&field_id| field_id != -2)
1570            });
1571        }
1572    }
1573    /// Coverage of an index that a rewrite invalidates: the rewritten fragments are
1574    /// removed and the fragments they became are *not* added.
1575    fn drop_rewritten_fragments(old: &RoaringBitmap, groups: &[RewriteGroup]) -> RoaringBitmap {
1576        let mut new_bitmap = old.clone();
1577        for group in groups {
1578            for old_fragment in &group.old_fragments {
1579                new_bitmap.remove(old_fragment.id as u32);
1580            }
1581        }
1582        new_bitmap
1583    }
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589    use crate::format::overlay::OverlayCoverage;
1590    use crate::format::pb;
1591    use crate::format::{RowDatasetVersionMeta, RowDatasetVersionSequence, RowIdMeta};
1592    use crate::rowids::{RowIdSequence, write_row_ids};
1593    use crate::transaction::test_support::{
1594        default_build_config, last_updated_at_versions, make_stable_row_id_manifest,
1595        overlay_with_field, sample_index_metadata, sample_manifest,
1596    };
1597    use crate::transaction::{DataOverlayGroup, UpdateMode, validate_operation};
1598    use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
1599    use lance_core::datatypes::Schema as LanceSchema;
1600    use lance_file::version::{ConcreteFileVersion, LanceFileVersion};
1601    use lance_io::utils::CachedFileSize;
1602    use std::collections::HashMap;
1603    use std::sync::Arc;
1604
1605    fn sample_manifest_with_fragments(ids: std::ops::Range<u64>) -> Manifest {
1606        let schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
1607        Manifest::new(
1608            LanceSchema::try_from(&schema).unwrap(),
1609            Arc::new(ids.map(Fragment::new).collect()),
1610            DataStorageFormat::new(ConcreteFileVersion::V2_0),
1611            HashMap::new(),
1612        )
1613    }
1614
1615    #[test]
1616    fn test_create_index_build_manifest_keeps_unremoved_same_name_indices() {
1617        let manifest = sample_manifest();
1618        let first_index = sample_index_metadata("vector_idx");
1619        let second_index = sample_index_metadata("vector_idx");
1620        let third_index = sample_index_metadata("vector_idx");
1621
1622        let transaction = Transaction::new(
1623            manifest.version,
1624            Operation::CreateIndex {
1625                new_indices: vec![third_index.clone()],
1626                removed_indices: vec![second_index.clone()],
1627            },
1628            None,
1629        );
1630
1631        let (_, final_indices) = transaction
1632            .build_manifest(
1633                Some(&manifest),
1634                vec![first_index.clone(), second_index.clone()],
1635                "txn",
1636                &default_build_config(),
1637            )
1638            .unwrap();
1639
1640        assert_eq!(final_indices.len(), 2);
1641        assert!(final_indices.iter().any(|idx| idx.uuid == first_index.uuid));
1642        assert!(final_indices.iter().any(|idx| idx.uuid == third_index.uuid));
1643        assert!(
1644            !final_indices
1645                .iter()
1646                .any(|idx| idx.uuid == second_index.uuid)
1647        );
1648    }
1649
1650    #[test]
1651    fn test_create_index_build_manifest_deduplicates_relisted_indices_by_uuid() {
1652        let manifest = sample_manifest();
1653        let first_index = sample_index_metadata("vector_idx");
1654        let second_index = sample_index_metadata("vector_idx");
1655        let third_index = sample_index_metadata("vector_idx");
1656
1657        let transaction = Transaction::new(
1658            manifest.version,
1659            Operation::CreateIndex {
1660                new_indices: vec![first_index.clone(), third_index.clone()],
1661                removed_indices: vec![second_index.clone()],
1662            },
1663            None,
1664        );
1665
1666        let (_, final_indices) = transaction
1667            .build_manifest(
1668                Some(&manifest),
1669                vec![first_index.clone(), second_index.clone()],
1670                "txn",
1671                &default_build_config(),
1672            )
1673            .unwrap();
1674
1675        assert_eq!(final_indices.len(), 2);
1676        assert_eq!(
1677            final_indices
1678                .iter()
1679                .filter(|idx| idx.uuid == first_index.uuid)
1680                .count(),
1681            1
1682        );
1683        assert!(final_indices.iter().any(|idx| idx.uuid == third_index.uuid));
1684        assert!(
1685            !final_indices
1686                .iter()
1687                .any(|idx| idx.uuid == second_index.uuid)
1688        );
1689    }
1690
1691    #[test]
1692    fn test_update_build_manifest_replaces_and_removes_fragments() {
1693        let manifest = sample_manifest_with_fragments(0..5);
1694
1695        let mut updated2 = Fragment::new(2);
1696        updated2.physical_rows = Some(42);
1697        let mut updated4 = Fragment::new(4);
1698        updated4.physical_rows = Some(43);
1699
1700        let transaction = Transaction::new(
1701            manifest.version,
1702            Operation::Update {
1703                removed_fragment_ids: vec![1],
1704                // Fragment 99 does not exist in the dataset; it must be ignored,
1705                // not appended.
1706                updated_fragments: vec![updated2, updated4, Fragment::new(99)],
1707                new_fragments: vec![],
1708                fields_modified: vec![],
1709                compacted_sstables: vec![],
1710                fields_for_preserving_frag_bitmap: vec![],
1711                update_mode: None,
1712                inserted_rows_filter: None,
1713                updated_fragment_offsets: None,
1714            },
1715            None,
1716        );
1717
1718        let (new_manifest, _) = transaction
1719            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
1720            .unwrap();
1721
1722        let ids: Vec<u64> = new_manifest.fragments.iter().map(|f| f.id).collect();
1723        assert_eq!(ids, vec![0, 2, 3, 4]);
1724        let rows: Vec<Option<usize>> = new_manifest
1725            .fragments
1726            .iter()
1727            .map(|f| f.physical_rows)
1728            .collect();
1729        assert_eq!(rows, vec![None, Some(42), None, Some(43)]);
1730    }
1731
1732    #[test]
1733    fn test_delete_build_manifest_replaces_and_removes_fragments() {
1734        let manifest = sample_manifest_with_fragments(0..5);
1735
1736        let mut updated2 = Fragment::new(2);
1737        updated2.physical_rows = Some(42);
1738
1739        let transaction = Transaction::new(
1740            manifest.version,
1741            Operation::Delete {
1742                updated_fragments: vec![updated2],
1743                deleted_fragment_ids: vec![1, 3],
1744                predicate: "id > 0".to_string(),
1745            },
1746            None,
1747        );
1748
1749        let (new_manifest, _) = transaction
1750            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
1751            .unwrap();
1752
1753        let ids: Vec<u64> = new_manifest.fragments.iter().map(|f| f.id).collect();
1754        assert_eq!(ids, vec![0, 2, 4]);
1755        let rows: Vec<Option<usize>> = new_manifest
1756            .fragments
1757            .iter()
1758            .map(|f| f.physical_rows)
1759            .collect();
1760        assert_eq!(rows, vec![None, Some(42), None]);
1761    }
1762
1763    #[test]
1764    fn test_remove_tombstoned_data_files() {
1765        // Create a fragment with mixed data files: some normal, some fully tombstoned
1766        let mut fragment = Fragment::new(1);
1767
1768        // Add a normal data file with valid field IDs
1769        fragment.files.push(DataFile {
1770            path: "normal.lance".to_string(),
1771            fields: Arc::from([1, 2, 3]),
1772            column_indices: Arc::from([]),
1773            file_major_version: 2,
1774            file_minor_version: 0,
1775            file_size_bytes: CachedFileSize::new(1000),
1776            base_id: None,
1777        });
1778
1779        // Add a data file with all fields tombstoned
1780        fragment.files.push(DataFile {
1781            path: "all_tombstoned.lance".to_string(),
1782            fields: Arc::from([-2, -2, -2]),
1783            column_indices: Arc::from([]),
1784            file_major_version: 2,
1785            file_minor_version: 0,
1786            file_size_bytes: CachedFileSize::new(500),
1787            base_id: None,
1788        });
1789
1790        // Add a data file with mixed tombstoned and valid fields
1791        fragment.files.push(DataFile {
1792            path: "mixed.lance".to_string(),
1793            fields: Arc::from([4, -2, 5]),
1794            column_indices: Arc::from([]),
1795            file_major_version: 2,
1796            file_minor_version: 0,
1797            file_size_bytes: CachedFileSize::new(750),
1798            base_id: None,
1799        });
1800
1801        // Add another fully tombstoned file
1802        fragment.files.push(DataFile {
1803            path: "another_tombstoned.lance".to_string(),
1804            fields: Arc::from([-2_i32]),
1805            column_indices: Arc::from([]),
1806            file_major_version: 2,
1807            file_minor_version: 0,
1808            file_size_bytes: CachedFileSize::new(250),
1809            base_id: None,
1810        });
1811
1812        let mut fragments = vec![fragment];
1813
1814        // Apply the cleanup
1815        Transaction::remove_tombstoned_data_files(&mut fragments);
1816
1817        // Should have removed the two fully tombstoned files
1818        assert_eq!(fragments[0].files.len(), 2);
1819        assert_eq!(fragments[0].files[0].path, "normal.lance");
1820        assert_eq!(fragments[0].files[1].path, "mixed.lance");
1821    }
1822
1823    /// When a fragment has no existing last_updated_at_version_meta (None), a
1824    /// partial RewriteColumns refresh must leave it as None rather than fabricating
1825    /// prev_version for unmatched rows.
1826    #[test]
1827    fn test_partial_rewrite_skips_fragment_with_no_version_meta() {
1828        let row_ids = RowIdSequence::from([10u64, 11, 12, 13, 14].as_slice());
1829        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
1830
1831        let data_file = DataFile::new(
1832            "data.lance",
1833            vec![0],
1834            vec![0],
1835            LanceFileVersion::Stable.resolve(),
1836            None,
1837            None,
1838        );
1839
1840        let fragment = Fragment {
1841            id: 1,
1842            files: vec![data_file],
1843            overlays: vec![],
1844            deletion_file: None,
1845            row_id_meta,
1846            physical_rows: Some(5),
1847            last_updated_at_version_meta: None,
1848            created_at_version_meta: None,
1849        };
1850
1851        let manifest = make_stable_row_id_manifest(vec![fragment.clone()]);
1852
1853        // Simulate a RewriteColumns update that matched offsets 1 and 3
1854        let off_map = HashMap::from([(1u64, RoaringBitmap::from_iter([1u32, 3]))]);
1855        let tx = Transaction::new(
1856            manifest.version,
1857            Operation::Update {
1858                removed_fragment_ids: vec![],
1859                updated_fragments: vec![fragment],
1860                new_fragments: vec![],
1861                fields_modified: vec![],
1862                compacted_sstables: vec![],
1863                fields_for_preserving_frag_bitmap: vec![],
1864                update_mode: Some(UpdateMode::RewriteColumns),
1865                inserted_rows_filter: None,
1866                updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map)),
1867            },
1868            None,
1869        );
1870
1871        let (out, _) = tx
1872            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
1873            .unwrap();
1874
1875        assert!(
1876            out.fragments[0].last_updated_at_version_meta.is_none(),
1877            "fragment with no prior version metadata must not have fabricated prev_version stamped on unmatched rows"
1878        );
1879    }
1880
1881    #[test]
1882    fn test_bitmap_cardinality_exceeds_physical_rows() {
1883        let row_ids = RowIdSequence::from([10u64, 11, 12, 13, 14].as_slice());
1884        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
1885
1886        let data_file = DataFile::new(
1887            "data.lance",
1888            vec![0],
1889            vec![0],
1890            LanceFileVersion::Stable.resolve(),
1891            None,
1892            None,
1893        );
1894
1895        let version_seq = RowDatasetVersionSequence::from_uniform_row_count(5, 1);
1896        let version_meta = RowDatasetVersionMeta::from_sequence(&version_seq).unwrap();
1897
1898        let fragment = Fragment {
1899            id: 1,
1900            files: vec![data_file],
1901            overlays: vec![],
1902            deletion_file: None,
1903            row_id_meta,
1904            physical_rows: Some(5),
1905            last_updated_at_version_meta: Some(version_meta.clone()),
1906            created_at_version_meta: Some(version_meta),
1907        };
1908
1909        let manifest = make_stable_row_id_manifest(vec![fragment.clone()]);
1910
1911        // Bitmap with 10 offsets but fragment only has 5 physical rows.
1912        let off_map = HashMap::from([(1u64, RoaringBitmap::from_iter(0u32..10))]);
1913        let tx = Transaction::new(
1914            manifest.version,
1915            Operation::Update {
1916                removed_fragment_ids: vec![],
1917                updated_fragments: vec![fragment],
1918                new_fragments: vec![],
1919                fields_modified: vec![],
1920                compacted_sstables: vec![],
1921                fields_for_preserving_frag_bitmap: vec![],
1922                update_mode: Some(UpdateMode::RewriteColumns),
1923                inserted_rows_filter: None,
1924                updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map)),
1925            },
1926            None,
1927        );
1928
1929        let result = tx.build_manifest(Some(&manifest), vec![], "txn", &default_build_config());
1930        assert!(result.is_err());
1931        let msg = result.unwrap_err().to_string();
1932        assert!(
1933            msg.contains("cardinality"),
1934            "expected cardinality error, got: {msg}"
1935        );
1936    }
1937
1938    #[test]
1939    fn test_bitmap_max_offset_exceeds_physical_rows() {
1940        let row_ids = RowIdSequence::from([10u64, 11, 12, 13, 14].as_slice());
1941        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
1942
1943        let data_file = DataFile::new(
1944            "data.lance",
1945            vec![0],
1946            vec![0],
1947            LanceFileVersion::Stable.resolve(),
1948            None,
1949            None,
1950        );
1951
1952        let version_seq = RowDatasetVersionSequence::from_uniform_row_count(5, 1);
1953        let version_meta = RowDatasetVersionMeta::from_sequence(&version_seq).unwrap();
1954
1955        let fragment = Fragment {
1956            id: 1,
1957            files: vec![data_file],
1958            overlays: vec![],
1959            deletion_file: None,
1960            row_id_meta,
1961            physical_rows: Some(5),
1962            last_updated_at_version_meta: Some(version_meta.clone()),
1963            created_at_version_meta: Some(version_meta),
1964        };
1965
1966        let manifest = make_stable_row_id_manifest(vec![fragment.clone()]);
1967
1968        // Only 2 offsets (within cardinality) but max offset 100 exceeds physical_rows 5.
1969        let off_map = HashMap::from([(1u64, RoaringBitmap::from_iter([0u32, 100]))]);
1970        let tx = Transaction::new(
1971            manifest.version,
1972            Operation::Update {
1973                removed_fragment_ids: vec![],
1974                updated_fragments: vec![fragment],
1975                new_fragments: vec![],
1976                fields_modified: vec![],
1977                compacted_sstables: vec![],
1978                fields_for_preserving_frag_bitmap: vec![],
1979                update_mode: Some(UpdateMode::RewriteColumns),
1980                inserted_rows_filter: None,
1981                updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map)),
1982            },
1983            None,
1984        );
1985
1986        let result = tx.build_manifest(Some(&manifest), vec![], "txn", &default_build_config());
1987        assert!(result.is_err());
1988        let msg = result.unwrap_err().to_string();
1989        assert!(
1990            msg.contains("max offset"),
1991            "expected max offset error, got: {msg}"
1992        );
1993    }
1994
1995    #[test]
1996    fn test_bitmap_at_exact_physical_rows_boundary_succeeds() {
1997        let row_ids = RowIdSequence::from([10u64, 11, 12, 13, 14].as_slice());
1998        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
1999
2000        let data_file = DataFile::new(
2001            "data.lance",
2002            vec![0],
2003            vec![0],
2004            LanceFileVersion::Stable.resolve(),
2005            None,
2006            None,
2007        );
2008
2009        let version_seq = RowDatasetVersionSequence::from_uniform_row_count(5, 1);
2010        let version_meta = RowDatasetVersionMeta::from_sequence(&version_seq).unwrap();
2011
2012        let fragment = Fragment {
2013            id: 1,
2014            files: vec![data_file],
2015            overlays: vec![],
2016            deletion_file: None,
2017            row_id_meta,
2018            physical_rows: Some(5),
2019            last_updated_at_version_meta: Some(version_meta.clone()),
2020            created_at_version_meta: Some(version_meta),
2021        };
2022
2023        let manifest = make_stable_row_id_manifest(vec![fragment.clone()]);
2024
2025        // All 5 offsets on a 5-row fragment — exactly at the boundary, should succeed.
2026        let off_map = HashMap::from([(1u64, RoaringBitmap::from_iter(0u32..5))]);
2027        let tx = Transaction::new(
2028            manifest.version,
2029            Operation::Update {
2030                removed_fragment_ids: vec![],
2031                updated_fragments: vec![fragment],
2032                new_fragments: vec![],
2033                fields_modified: vec![],
2034                compacted_sstables: vec![],
2035                fields_for_preserving_frag_bitmap: vec![],
2036                update_mode: Some(UpdateMode::RewriteColumns),
2037                inserted_rows_filter: None,
2038                updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map)),
2039            },
2040            None,
2041        );
2042
2043        tx.build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2044            .expect("bitmap at exact physical_rows boundary should succeed");
2045    }
2046
2047    #[test]
2048    fn test_updated_fragment_offsets_key_not_in_updated_fragments_is_rejected() {
2049        // Fragment A is being rewritten; fragment B exists in the manifest but is
2050        // NOT in updated_fragments. Supplying an offset key for B must be rejected
2051        // so that B's version metadata cannot be stamped by an unrelated commit.
2052        let make_fragment = |id: u64| {
2053            let row_ids = RowIdSequence::from([id * 10].as_slice());
2054            let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
2055            Fragment {
2056                id,
2057                files: vec![DataFile::new(
2058                    format!("{id}.lance"),
2059                    vec![0],
2060                    vec![0],
2061                    LanceFileVersion::Stable.resolve(),
2062                    None,
2063                    None,
2064                )],
2065                overlays: vec![],
2066                deletion_file: None,
2067                row_id_meta,
2068                physical_rows: Some(5),
2069                last_updated_at_version_meta: None,
2070                created_at_version_meta: None,
2071            }
2072        };
2073
2074        let frag_a = make_fragment(1);
2075        let frag_b = make_fragment(2);
2076        let manifest = make_stable_row_id_manifest(vec![frag_a.clone(), frag_b.clone()]);
2077
2078        // updated_fragments contains only A; offsets are keyed to B — must fail.
2079        let off_map = HashMap::from([(frag_b.id, RoaringBitmap::from_iter([0u32, 1, 2]))]);
2080        let operation = Operation::Update {
2081            removed_fragment_ids: vec![],
2082            updated_fragments: vec![frag_a],
2083            new_fragments: vec![],
2084            fields_modified: vec![],
2085            compacted_sstables: vec![],
2086            fields_for_preserving_frag_bitmap: vec![],
2087            update_mode: Some(UpdateMode::RewriteColumns),
2088            inserted_rows_filter: None,
2089            updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map)),
2090        };
2091
2092        let err = validate_operation(Some(&manifest), &operation).unwrap_err();
2093        assert!(
2094            err.to_string().contains("not in updated_fragments"),
2095            "expected key-presence error, got: {err}"
2096        );
2097    }
2098
2099    #[test]
2100    fn test_proto_round_trip_field_10() {
2101        let off_map = HashMap::from([
2102            (1u64, RoaringBitmap::from_iter([1u32, 3, 5])),
2103            (2u64, RoaringBitmap::from_iter([0u32, 2, 4, 6])),
2104        ]);
2105        let tx = Transaction::new(
2106            1,
2107            Operation::Update {
2108                removed_fragment_ids: vec![],
2109                updated_fragments: vec![],
2110                new_fragments: vec![],
2111                fields_modified: vec![],
2112                compacted_sstables: vec![],
2113                fields_for_preserving_frag_bitmap: vec![],
2114                update_mode: Some(UpdateMode::RewriteColumns),
2115                inserted_rows_filter: None,
2116                updated_fragment_offsets: Some(UpdatedFragmentOffsets(off_map.clone())),
2117            },
2118            None,
2119        );
2120
2121        let pb_tx: pb::Transaction = pb::Transaction::from(&tx);
2122
2123        // Field 9 must be empty; field 10 must be populated.
2124        if let Some(pb::transaction::Operation::Update(ref update)) = pb_tx.operation {
2125            assert!(
2126                update.updated_fragment_offsets.is_empty(),
2127                "field 9 should be empty"
2128            );
2129            assert_eq!(update.updated_fragment_offset_bitmaps.len(), 2);
2130        } else {
2131            panic!("expected Update operation");
2132        }
2133
2134        let tx2 = Transaction::try_from(pb_tx).unwrap();
2135        if let Operation::Update {
2136            updated_fragment_offsets: Some(UpdatedFragmentOffsets(m)),
2137            ..
2138        } = &tx2.operation
2139        {
2140            assert_eq!(m.len(), 2);
2141            assert_eq!(*m.get(&1).unwrap(), off_map[&1]);
2142            assert_eq!(*m.get(&2).unwrap(), off_map[&2]);
2143        } else {
2144            panic!("expected Update with offsets");
2145        }
2146    }
2147
2148    #[test]
2149    fn test_proto_legacy_field_9_read() {
2150        // Simulate a manifest written by old Lance: only field 9, no field 10.
2151        let pb_tx = pb::Transaction {
2152            read_version: 1,
2153            uuid: "test".to_string(),
2154            tag: String::new(),
2155            transaction_properties: HashMap::new(),
2156            operation: Some(pb::transaction::Operation::Update(
2157                pb::transaction::Update {
2158                    removed_fragment_ids: vec![],
2159                    updated_fragments: vec![],
2160                    new_fragments: vec![],
2161                    fields_modified: vec![],
2162                    compacted_sstables: vec![],
2163                    fields_for_preserving_frag_bitmap: vec![],
2164                    update_mode: 1,
2165                    inserted_rows: None,
2166                    updated_fragment_offsets: HashMap::from([(
2167                        1u64,
2168                        pb::transaction::UInt32List {
2169                            values: vec![1, 3, 5],
2170                        },
2171                    )]),
2172                    updated_fragment_offset_bitmaps: HashMap::new(),
2173                },
2174            )),
2175        };
2176
2177        let tx = Transaction::try_from(pb_tx).unwrap();
2178        if let Operation::Update {
2179            updated_fragment_offsets: Some(UpdatedFragmentOffsets(m)),
2180            ..
2181        } = &tx.operation
2182        {
2183            assert_eq!(m.len(), 1);
2184            let bitmap = m.get(&1).unwrap();
2185            let offsets: Vec<u32> = bitmap.iter().collect();
2186            assert_eq!(offsets, vec![1, 3, 5]);
2187        } else {
2188            panic!("expected Update with offsets from legacy field 9");
2189        }
2190    }
2191
2192    #[test]
2193    fn test_proto_field_10_takes_precedence_over_field_9() {
2194        // When both fields present, field 10 wins.
2195        let mut bitmap_bytes = Vec::new();
2196        RoaringBitmap::from_iter([10u32, 20, 30])
2197            .serialize_into(&mut bitmap_bytes)
2198            .unwrap();
2199
2200        let pb_tx = pb::Transaction {
2201            read_version: 1,
2202            uuid: "test".to_string(),
2203            tag: String::new(),
2204            transaction_properties: HashMap::new(),
2205            operation: Some(pb::transaction::Operation::Update(
2206                pb::transaction::Update {
2207                    removed_fragment_ids: vec![],
2208                    updated_fragments: vec![],
2209                    new_fragments: vec![],
2210                    fields_modified: vec![],
2211                    compacted_sstables: vec![],
2212                    fields_for_preserving_frag_bitmap: vec![],
2213                    update_mode: 1,
2214                    inserted_rows: None,
2215                    // Field 9 has different values than field 10.
2216                    updated_fragment_offsets: HashMap::from([(
2217                        1u64,
2218                        pb::transaction::UInt32List {
2219                            values: vec![99, 100],
2220                        },
2221                    )]),
2222                    updated_fragment_offset_bitmaps: HashMap::from([(1u64, bitmap_bytes)]),
2223                },
2224            )),
2225        };
2226
2227        let tx = Transaction::try_from(pb_tx).unwrap();
2228        if let Operation::Update {
2229            updated_fragment_offsets: Some(UpdatedFragmentOffsets(m)),
2230            ..
2231        } = &tx.operation
2232        {
2233            let offsets: Vec<u32> = m.get(&1).unwrap().iter().collect();
2234            assert_eq!(offsets, vec![10, 20, 30], "field 10 should take precedence");
2235        } else {
2236            panic!("expected Update with offsets from field 10");
2237        }
2238    }
2239
2240    #[test]
2241    fn merge_build_manifest_refreshes_last_updated_when_data_files_change_stable_row_ids() {
2242        use crate::feature_flags::FLAG_STABLE_ROW_IDS;
2243        use lance_file::version::LanceFileVersion;
2244
2245        let mk_file = |path: &str| {
2246            DataFile::new(
2247                path,
2248                vec![0],
2249                vec![0],
2250                LanceFileVersion::Stable.resolve(),
2251                None,
2252                None,
2253            )
2254        };
2255
2256        let arrow_schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2257        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
2258
2259        let row_ids = RowIdSequence::from([100u64, 101, 102, 103, 104].as_slice());
2260        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
2261
2262        let prev_fragment = Fragment {
2263            id: 0,
2264            files: vec![mk_file("before.lance")],
2265            overlays: vec![],
2266            deletion_file: None,
2267            row_id_meta,
2268            physical_rows: Some(5),
2269            last_updated_at_version_meta: None,
2270            created_at_version_meta: None,
2271        };
2272
2273        let mut manifest = Manifest::new(
2274            lance_schema.clone(),
2275            Arc::new(vec![prev_fragment.clone()]),
2276            DataStorageFormat::new(ConcreteFileVersion::V2_0),
2277            HashMap::new(),
2278        );
2279        manifest.reader_feature_flags |= FLAG_STABLE_ROW_IDS;
2280        manifest.next_row_id = 100;
2281
2282        let merged_fragment = Fragment {
2283            files: vec![mk_file("after.lance")],
2284            ..prev_fragment
2285        };
2286
2287        let tx = Transaction::new(
2288            manifest.version,
2289            Operation::Merge {
2290                fragments: vec![merged_fragment],
2291                schema: lance_schema,
2292                preserves_nullability: true,
2293            },
2294            None,
2295        );
2296
2297        let (out, _) = tx
2298            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2299            .unwrap();
2300
2301        assert_eq!(out.version, 2);
2302        let frag = &out.fragments[0];
2303        let seq = frag
2304            .last_updated_at_version_meta
2305            .as_ref()
2306            .unwrap()
2307            .load_sequence()
2308            .unwrap();
2309        assert_eq!(seq.version_at(0).unwrap(), 2);
2310        assert_eq!(seq.version_at(4).unwrap(), 2);
2311    }
2312
2313    #[test]
2314    fn merge_build_manifest_skips_refresh_when_carry_forward_stable_row_ids() {
2315        use crate::feature_flags::FLAG_STABLE_ROW_IDS;
2316        use crate::rowids::version::{RowDatasetVersionMeta, RowDatasetVersionSequence};
2317        use lance_file::version::LanceFileVersion;
2318
2319        let data_file = DataFile::new(
2320            "same.lance",
2321            vec![0],
2322            vec![0],
2323            LanceFileVersion::Stable.resolve(),
2324            None,
2325            None,
2326        );
2327
2328        let arrow_schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2329        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
2330
2331        let row_ids = RowIdSequence::from([200u64, 201, 202, 203, 204].as_slice());
2332        let row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&row_ids).into()));
2333
2334        let uniform_v1 = RowDatasetVersionSequence::from_uniform_row_count(5, 1);
2335        let meta_v1 = RowDatasetVersionMeta::from_sequence(&uniform_v1).unwrap();
2336
2337        let prev_fragment = Fragment {
2338            id: 0,
2339            files: vec![data_file.clone()],
2340            overlays: vec![],
2341            deletion_file: None,
2342            row_id_meta: row_id_meta.clone(),
2343            physical_rows: Some(5),
2344            last_updated_at_version_meta: Some(meta_v1.clone()),
2345            created_at_version_meta: None,
2346        };
2347
2348        let mut manifest = Manifest::new(
2349            lance_schema.clone(),
2350            Arc::new(vec![prev_fragment]),
2351            DataStorageFormat::new(ConcreteFileVersion::V2_0),
2352            HashMap::new(),
2353        );
2354        manifest.reader_feature_flags |= FLAG_STABLE_ROW_IDS;
2355        manifest.next_row_id = 100;
2356
2357        let merged_fragment = Fragment {
2358            id: 0,
2359            files: vec![data_file],
2360            overlays: vec![],
2361            deletion_file: None,
2362            row_id_meta,
2363            physical_rows: Some(5),
2364            last_updated_at_version_meta: Some(meta_v1),
2365            created_at_version_meta: None,
2366        };
2367
2368        let tx = Transaction::new(
2369            manifest.version,
2370            Operation::Merge {
2371                fragments: vec![merged_fragment],
2372                schema: lance_schema,
2373                preserves_nullability: true,
2374            },
2375            None,
2376        );
2377
2378        let (out, _) = tx
2379            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2380            .unwrap();
2381
2382        let seq = out.fragments[0]
2383            .last_updated_at_version_meta
2384            .as_ref()
2385            .unwrap()
2386            .load_sequence()
2387            .unwrap();
2388        assert_eq!(seq.version_at(0).unwrap(), 1);
2389        assert_eq!(seq.version_at(4).unwrap(), 1);
2390    }
2391
2392    #[test]
2393    fn merge_build_manifest_no_last_updated_refresh_without_stable_row_ids() {
2394        use crate::feature_flags::FLAG_STABLE_ROW_IDS;
2395        use lance_file::version::LanceFileVersion;
2396
2397        let mk_file = |path: &str| {
2398            DataFile::new(
2399                path,
2400                vec![0],
2401                vec![0],
2402                LanceFileVersion::Stable.resolve(),
2403                None,
2404                None,
2405            )
2406        };
2407
2408        let arrow_schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2409        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
2410
2411        let prev_fragment = Fragment {
2412            id: 0,
2413            files: vec![mk_file("before.lance")],
2414            overlays: vec![],
2415            deletion_file: None,
2416            row_id_meta: None,
2417            physical_rows: Some(5),
2418            last_updated_at_version_meta: None,
2419            created_at_version_meta: None,
2420        };
2421
2422        let manifest = Manifest::new(
2423            lance_schema.clone(),
2424            Arc::new(vec![prev_fragment.clone()]),
2425            DataStorageFormat::new(ConcreteFileVersion::V2_0),
2426            HashMap::new(),
2427        );
2428        assert_eq!(
2429            manifest.reader_feature_flags & FLAG_STABLE_ROW_IDS,
2430            0,
2431            "manifest must not use stable row IDs for this guard test"
2432        );
2433
2434        let merged_fragment = Fragment {
2435            files: vec![mk_file("after.lance")],
2436            ..prev_fragment
2437        };
2438
2439        let tx = Transaction::new(
2440            manifest.version,
2441            Operation::Merge {
2442                fragments: vec![merged_fragment],
2443                schema: lance_schema,
2444                preserves_nullability: true,
2445            },
2446            None,
2447        );
2448
2449        let (out, _) = tx
2450            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2451            .unwrap();
2452
2453        assert!(
2454            out.fragments[0].last_updated_at_version_meta.is_none(),
2455            "without stable row IDs, Merge must not populate per-row last_updated metadata"
2456        );
2457    }
2458
2459    #[test]
2460    fn merge_build_manifest_sets_both_version_meta_for_new_fragment_id_stable_row_ids() {
2461        use crate::feature_flags::FLAG_STABLE_ROW_IDS;
2462        use lance_file::version::LanceFileVersion;
2463
2464        let mk_file = |path: &str| {
2465            DataFile::new(
2466                path,
2467                vec![0],
2468                vec![0],
2469                LanceFileVersion::Stable.resolve(),
2470                None,
2471                None,
2472            )
2473        };
2474
2475        let arrow_schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2476        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
2477
2478        // Existing fragment (id=0) with stable row IDs
2479        let row_ids_0 = RowIdSequence::from([10u64, 11, 12].as_slice());
2480        let existing_fragment = Fragment {
2481            id: 0,
2482            files: vec![mk_file("existing.lance")],
2483            overlays: vec![],
2484            deletion_file: None,
2485            row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&row_ids_0).into())),
2486            physical_rows: Some(3),
2487            last_updated_at_version_meta: None,
2488            created_at_version_meta: None,
2489        };
2490
2491        let mut manifest = Manifest::new(
2492            lance_schema.clone(),
2493            Arc::new(vec![existing_fragment.clone()]),
2494            DataStorageFormat::new(ConcreteFileVersion::V2_0),
2495            HashMap::new(),
2496        );
2497        manifest.reader_feature_flags |= FLAG_STABLE_ROW_IDS;
2498        manifest.next_row_id = 100;
2499        manifest.version = 1;
2500
2501        // New fragment (id=1) not present in prev manifest — exercises the None branch
2502        let row_ids_1 = RowIdSequence::from([20u64, 21, 22, 23].as_slice());
2503        let new_fragment = Fragment {
2504            id: 1,
2505            files: vec![mk_file("new.lance")],
2506            overlays: vec![],
2507            deletion_file: None,
2508            row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&row_ids_1).into())),
2509            physical_rows: Some(4),
2510            last_updated_at_version_meta: None,
2511            created_at_version_meta: None,
2512        };
2513
2514        let tx = Transaction::new(
2515            manifest.version,
2516            Operation::Merge {
2517                fragments: vec![existing_fragment, new_fragment],
2518                schema: lance_schema,
2519                preserves_nullability: true,
2520            },
2521            None,
2522        );
2523
2524        let (out, _) = tx
2525            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2526            .unwrap();
2527
2528        assert_eq!(out.version, 2);
2529
2530        let new_frag = out.fragments.iter().find(|f| f.id == 1).unwrap();
2531
2532        // last_updated_at_version must be set to the commit version
2533        let last_updated_seq = new_frag
2534            .last_updated_at_version_meta
2535            .as_ref()
2536            .expect("new fragment must have last_updated_at_version_meta")
2537            .load_sequence()
2538            .unwrap();
2539        assert_eq!(last_updated_seq.version_at(0).unwrap(), 2);
2540        assert_eq!(last_updated_seq.version_at(3).unwrap(), 2);
2541
2542        // created_at_version must also be set — must not be None
2543        let created_seq = new_frag
2544            .created_at_version_meta
2545            .as_ref()
2546            .expect("new fragment must have created_at_version_meta")
2547            .load_sequence()
2548            .unwrap();
2549        assert_eq!(created_seq.version_at(0).unwrap(), 2);
2550        assert_eq!(created_seq.version_at(3).unwrap(), 2);
2551    }
2552
2553    #[test]
2554    fn test_data_overlay_build_manifest_multi_fragment() {
2555        // Overlays targeting two distinct fragments are each applied and stamped.
2556        // A targeted fragment already carrying an overlay (committed at v3) gets
2557        // the new overlay appended and stamped while its existing overlay is
2558        // preserved, and a fragment the operation does not target is passed
2559        // through with its existing overlays untouched.
2560        let mut frag0 = Fragment::new(0);
2561        frag0.overlays = vec![overlay_with_field(5, 3)]; // targeted, pre-existing at v3
2562        let frag1 = Fragment::new(1);
2563        let mut frag2 = Fragment::new(2);
2564        frag2.overlays = vec![overlay_with_field(9, 3)]; // untargeted, committed at v3
2565        let schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2566        let mut manifest = Manifest::new(
2567            LanceSchema::try_from(&schema).unwrap(),
2568            Arc::new(vec![frag0, frag1, frag2]),
2569            crate::format::DataStorageFormat::new(ConcreteFileVersion::V2_0),
2570            HashMap::new(),
2571        );
2572        // The pre-existing overlays were committed at v3, so the current
2573        // manifest must be at least that version; the new commit then stamps
2574        // its overlay at v4, keeping the fragment's overlays newest-last.
2575        manifest.version = 3;
2576
2577        let txn = Transaction::new(
2578            manifest.version,
2579            Operation::DataOverlay {
2580                groups: vec![
2581                    DataOverlayGroup {
2582                        fragment_id: 0,
2583                        overlays: vec![overlay_with_field(1, 0)],
2584                    },
2585                    DataOverlayGroup {
2586                        fragment_id: 1,
2587                        overlays: vec![overlay_with_field(2, 0)],
2588                    },
2589                ],
2590            },
2591            None,
2592        );
2593
2594        let (result, _) = txn
2595            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2596            .unwrap();
2597
2598        let frag = |id: u64| {
2599            result
2600                .fragments
2601                .iter()
2602                .find(|f| f.id == id)
2603                .unwrap_or_else(|| panic!("fragment {id} missing from result"))
2604        };
2605        // The already-overlaid target keeps its v3 overlay and appends the new
2606        // one, stamped to the new version.
2607        assert_eq!(frag(0).overlays.len(), 2);
2608        assert_eq!(frag(0).overlays[0].committed_version, 3);
2609        assert_eq!(frag(0).overlays[1].committed_version, result.version);
2610        // The fresh target gets its overlay, stamped to the new version.
2611        assert_eq!(frag(1).overlays.len(), 1);
2612        assert_eq!(frag(1).overlays[0].committed_version, result.version);
2613        // The untargeted fragment is unchanged: same overlay, original version.
2614        assert_eq!(frag(2).overlays.len(), 1);
2615        assert_eq!(frag(2).overlays[0].committed_version, 3);
2616        assert!(result.version > manifest.version);
2617    }
2618
2619    #[test]
2620    fn test_data_overlay_build_manifest_updates_row_lineage() {
2621        let row_ids = RowIdSequence::from([10u64, 11, 12, 13].as_slice());
2622        let version_meta = RowDatasetVersionMeta::from_sequence(
2623            &RowDatasetVersionSequence::from_uniform_row_count(4, 1),
2624        )
2625        .unwrap();
2626        let fragment = Fragment {
2627            id: 1,
2628            files: vec![],
2629            overlays: vec![],
2630            deletion_file: None,
2631            row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&row_ids).into())),
2632            physical_rows: Some(4),
2633            last_updated_at_version_meta: Some(version_meta.clone()),
2634            created_at_version_meta: Some(version_meta),
2635        };
2636        // Stable-row-id migrations can leave old fragments without explicit
2637        // lineage metadata; scans expose those rows as version 1.
2638        let untracked_row_ids = RowIdSequence::from([20u64, 21].as_slice());
2639        let untracked_fragment = Fragment {
2640            id: 2,
2641            files: vec![],
2642            overlays: vec![],
2643            deletion_file: None,
2644            row_id_meta: Some(RowIdMeta::Inline(write_row_ids(&untracked_row_ids).into())),
2645            physical_rows: Some(2),
2646            last_updated_at_version_meta: None,
2647            created_at_version_meta: None,
2648        };
2649        let manifest = make_stable_row_id_manifest(vec![fragment, untracked_fragment]);
2650        let txn = Transaction::new(
2651            manifest.version,
2652            Operation::DataOverlay {
2653                groups: vec![
2654                    DataOverlayGroup {
2655                        fragment_id: 1,
2656                        overlays: vec![
2657                            DataOverlayFile {
2658                                data_file: DataFile::new_legacy_from_fields(
2659                                    "dense.lance",
2660                                    vec![1],
2661                                    None,
2662                                ),
2663                                coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([0u32])),
2664                                committed_version: 0,
2665                            },
2666                            DataOverlayFile {
2667                                data_file: DataFile::new_legacy_from_fields(
2668                                    "sparse.lance",
2669                                    vec![1, 2],
2670                                    None,
2671                                ),
2672                                coverage: OverlayCoverage::sparse(vec![
2673                                    RoaringBitmap::from_iter([2u32]),
2674                                    RoaringBitmap::from_iter([3u32]),
2675                                ]),
2676                                committed_version: 0,
2677                            },
2678                        ],
2679                    },
2680                    DataOverlayGroup {
2681                        fragment_id: 2,
2682                        overlays: vec![DataOverlayFile {
2683                            data_file: DataFile::new_legacy_from_fields(
2684                                "migrated.lance",
2685                                vec![1],
2686                                None,
2687                            ),
2688                            coverage: OverlayCoverage::dense(RoaringBitmap::from_iter([1u32])),
2689                            committed_version: 0,
2690                        }],
2691                    },
2692                ],
2693            },
2694            None,
2695        );
2696
2697        let (result, _) = txn
2698            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2699            .unwrap();
2700
2701        assert_eq!(last_updated_at_versions(&result, 1), vec![5, 1, 5, 5]);
2702        assert_eq!(last_updated_at_versions(&result, 2), vec![1, 5]);
2703        assert!(
2704            result.fragments[0]
2705                .overlays
2706                .iter()
2707                .all(|overlay| overlay.committed_version == result.version)
2708        );
2709    }
2710
2711    #[test]
2712    fn test_data_replacement_tombstones_overlaid_fields() {
2713        // A DataReplacement writing new base values for field 5 must stop any
2714        // overlay already shadowing those cells: field 5 is tombstoned in place
2715        // (preserving the overlay's field 3), and an overlay covering only field
2716        // 5 is dropped entirely. Both overlays predate the transaction's read
2717        // version, which is what makes the replacement the newer value.
2718        let mut fragment = Fragment::new(0);
2719        fragment.files = vec![
2720            DataFile::new_legacy_from_fields("f3.lance", vec![3], None),
2721            DataFile::new_legacy_from_fields("f5.lance", vec![5], None),
2722        ];
2723        fragment.overlays = vec![
2724            DataOverlayFile {
2725                data_file: DataFile::new_legacy_from_fields("o35.lance", vec![3, 5], None),
2726                coverage: OverlayCoverage::sparse(vec![
2727                    roaring::RoaringBitmap::from_iter([0u32]),
2728                    roaring::RoaringBitmap::from_iter([0u32]),
2729                ]),
2730                committed_version: 1,
2731            },
2732            DataOverlayFile {
2733                data_file: DataFile::new_legacy_from_fields("o5.lance", vec![5], None),
2734                coverage: OverlayCoverage::dense(roaring::RoaringBitmap::from_iter([0u32])),
2735                committed_version: 1,
2736            },
2737        ];
2738
2739        let schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]);
2740        let manifest = Manifest::new(
2741            LanceSchema::try_from(&schema).unwrap(),
2742            Arc::new(vec![fragment]),
2743            crate::format::DataStorageFormat::new(ConcreteFileVersion::V2_0),
2744            HashMap::new(),
2745        );
2746
2747        let txn = Transaction::new(
2748            manifest.version,
2749            Operation::DataReplacement {
2750                replacements: vec![DataReplacementGroup(
2751                    0,
2752                    DataFile::new_legacy_from_fields("f5-new.lance", vec![5], None),
2753                )],
2754            },
2755            None,
2756        );
2757
2758        let (result, _) = txn
2759            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2760            .unwrap();
2761
2762        let frag = &result.fragments[0];
2763        // The base data file for field 5 was swapped in.
2764        assert!(frag.files.iter().any(|f| f.path == "f5-new.lance"));
2765        // The [3, 5] overlay keeps field 3 and tombstones field 5; the [5]-only
2766        // overlay is dropped.
2767        assert_eq!(frag.overlays.len(), 1);
2768        assert_eq!(frag.overlays[0].data_file.fields.as_ref(), &[3, -2]);
2769    }
2770
2771    /// Replace `fields` in `fragment` at `read_version`, against a manifest
2772    /// at `manifest_version` whose schema declares field ids 3 ("x"), 4 ("a"),
2773    /// 5 ("v") and 6 ("y").
2774    fn replace_fields(
2775        fragment: Fragment,
2776        fields: Vec<i32>,
2777        manifest_version: u64,
2778        read_version: u64,
2779    ) -> Result<Fragment> {
2780        let schema = ArrowSchema::new(vec![
2781            ArrowField::new("x", DataType::Int32, true),
2782            ArrowField::new("a", DataType::Int32, true),
2783            ArrowField::new("v", DataType::Int32, true),
2784            ArrowField::new("y", DataType::Int32, true),
2785        ]);
2786        let mut lance_schema = LanceSchema::try_from(&schema).unwrap();
2787        lance_schema.fields[0].id = 3;
2788        lance_schema.fields[1].id = 4;
2789        lance_schema.fields[2].id = 5;
2790        lance_schema.fields[3].id = 6;
2791        let mut manifest = Manifest::new(
2792            lance_schema,
2793            Arc::new(vec![fragment]),
2794            crate::format::DataStorageFormat::new(ConcreteFileVersion::V2_0),
2795            HashMap::new(),
2796        );
2797        manifest.version = manifest_version;
2798
2799        let column_indices = (0..fields.len() as i32).collect();
2800        let txn = Transaction::new(
2801            read_version,
2802            Operation::DataReplacement {
2803                replacements: vec![DataReplacementGroup(
2804                    0,
2805                    DataFile::new(
2806                        "v-new.lance",
2807                        fields,
2808                        column_indices,
2809                        ConcreteFileVersion::V2_0,
2810                        None,
2811                        None,
2812                    ),
2813                )],
2814            },
2815            None,
2816        );
2817        txn.build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
2818            .map(|(manifest, _)| manifest.fragments[0].clone())
2819    }
2820
2821    /// Replace field 5 in `fragment` at `read_version`, against a manifest at
2822    /// `manifest_version`.
2823    fn replace_field_5(
2824        fragment: Fragment,
2825        manifest_version: u64,
2826        read_version: u64,
2827    ) -> Result<Fragment> {
2828        replace_fields(fragment, vec![5], manifest_version, read_version)
2829    }
2830
2831    #[test]
2832    fn test_data_replacement_rejects_subset_of_legacy_file() {
2833        // The V1 reader derives its page table offset from the first field in
2834        // the file metadata, so turning `[4, 5]` into `[-2, 5]` would leave
2835        // field 4 decoding from field 5's pages. With no exact match to swap,
2836        // the replacement must be rejected rather than corrupting the sibling.
2837        let mut fragment = Fragment::new(0);
2838        fragment.files = vec![DataFile::new_legacy_from_fields(
2839            "wide.lance",
2840            vec![4, 5],
2841            None,
2842        )];
2843
2844        let result = replace_field_5(fragment, 1, 1);
2845        assert!(
2846            result.is_err(),
2847            "legacy subset replacement must be rejected, got: {:?}",
2848            result.map(|fragment| fragment.files)
2849        );
2850    }
2851
2852    #[test]
2853    fn test_data_replacement_tombstones_fields_spanning_files() {
2854        // The replaced fields sit in two different wider files. Each file is
2855        // tombstoned for the field it holds and survives on its remaining
2856        // live one, with the new file answering for both.
2857        let mut fragment = Fragment::new(0);
2858        fragment.files = vec![
2859            DataFile::new(
2860                "ab.lance",
2861                vec![3, 4],
2862                vec![0, 1],
2863                ConcreteFileVersion::V2_0,
2864                None,
2865                None,
2866            ),
2867            DataFile::new(
2868                "cd.lance",
2869                vec![5, 6],
2870                vec![0, 1],
2871                ConcreteFileVersion::V2_0,
2872                None,
2873                None,
2874            ),
2875        ];
2876
2877        let fragment = replace_fields(fragment, vec![4, 5], 1, 1).unwrap();
2878        let file = |path| {
2879            fragment
2880                .files
2881                .iter()
2882                .find(|file| file.path == path)
2883                .unwrap_or_else(|| panic!("{path} survives on its live field"))
2884        };
2885        assert_eq!(file("ab.lance").fields.as_ref(), &[3, TOMBSTONE_FIELD_ID]);
2886        assert_eq!(file("cd.lance").fields.as_ref(), &[TOMBSTONE_FIELD_ID, 6]);
2887        assert!(fragment.files.iter().any(|file| file.path == "v-new.lance"));
2888    }
2889
2890    #[test]
2891    fn test_data_replacement_rejects_fields_spanning_a_legacy_file() {
2892        // Spanning is only resolvable while every covering file can be
2893        // tombstoned. A V1 file holding one of the replaced fields cannot,
2894        // so the replacement must be rejected rather than half applied.
2895        let mut fragment = Fragment::new(0);
2896        fragment.files = vec![
2897            DataFile::new(
2898                "ab.lance",
2899                vec![3, 4],
2900                vec![0, 1],
2901                ConcreteFileVersion::V2_0,
2902                None,
2903                None,
2904            ),
2905            DataFile::new_legacy_from_fields("cd.lance", vec![5, 6], None),
2906        ];
2907
2908        let result = replace_fields(fragment, vec![4, 5], 1, 1);
2909        assert!(
2910            result.is_err(),
2911            "spanning a legacy file must be rejected, got: {:?}",
2912            result.map(|fragment| fragment.files)
2913        );
2914    }
2915
2916    #[test]
2917    fn test_data_replacement_retombstones_wider_file() {
2918        // A wider file carrying a tombstone from an earlier round is
2919        // tombstoned again for the newly replaced field and survives on its
2920        // remaining live field.
2921        let mut fragment = Fragment::new(0);
2922        fragment.files = vec![DataFile::new(
2923            "wide.lance",
2924            vec![4, TOMBSTONE_FIELD_ID, 5],
2925            vec![0, 1, 2],
2926            ConcreteFileVersion::V2_0,
2927            None,
2928            None,
2929        )];
2930
2931        let fragment = replace_fields(fragment, vec![5], 1, 1).unwrap();
2932        let wide = fragment
2933            .files
2934            .iter()
2935            .find(|file| file.path == "wide.lance")
2936            .expect("wider file survives on its live field");
2937        assert_eq!(
2938            wide.fields.as_ref(),
2939            &[4, TOMBSTONE_FIELD_ID, TOMBSTONE_FIELD_ID]
2940        );
2941        assert!(fragment.files.iter().any(|file| file.path == "v-new.lance"));
2942    }
2943
2944    #[test]
2945    fn test_data_replacement_preserves_overlay_newer_than_snapshot() {
2946        // An overlay committed after this transaction read its snapshot holds
2947        // the newer value; the conflict resolver rebases the two precisely
2948        // because the overlay wins. Tombstoning it would discard a committed
2949        // write, so only overlays the transaction could have seen are superseded.
2950        let mut fragment = Fragment::new(0);
2951        // One wider file, so the replacement takes the tombstone-and-append path.
2952        fragment.files = vec![DataFile::new(
2953            "wide.lance",
2954            vec![4, 5],
2955            vec![0, 1],
2956            ConcreteFileVersion::V2_0,
2957            None,
2958            None,
2959        )];
2960        fragment.overlays = vec![DataOverlayFile {
2961            data_file: DataFile::new(
2962                "newer.lance",
2963                vec![5],
2964                vec![0],
2965                ConcreteFileVersion::V2_0,
2966                None,
2967                None,
2968            ),
2969            coverage: OverlayCoverage::dense(roaring::RoaringBitmap::from_iter([0u32])),
2970            committed_version: 7,
2971        }];
2972
2973        // Staged against version 6, i.e. before the overlay landed.
2974        let fragment = replace_field_5(fragment, 7, 6).unwrap();
2975        assert!(fragment.files.iter().any(|f| f.path == "v-new.lance"));
2976        assert_eq!(
2977            fragment.overlays.len(),
2978            1,
2979            "overlay committed after the snapshot must survive"
2980        );
2981        assert_eq!(fragment.overlays[0].data_file.fields.as_ref(), &[5]);
2982    }
2983
2984    #[test]
2985    fn test_data_overlay_build_manifest_merges_duplicate_groups() {
2986        // Two groups targeting the same fragment must both survive (a HashMap
2987        // collapse would have dropped the first).
2988        let manifest = sample_manifest();
2989        let txn = Transaction::new(
2990            manifest.version,
2991            Operation::DataOverlay {
2992                groups: vec![
2993                    DataOverlayGroup {
2994                        fragment_id: 0,
2995                        overlays: vec![overlay_with_field(1, 0)],
2996                    },
2997                    DataOverlayGroup {
2998                        fragment_id: 0,
2999                        overlays: vec![overlay_with_field(2, 0)],
3000                    },
3001                ],
3002            },
3003            None,
3004        );
3005
3006        let (result, _) = txn
3007            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
3008            .unwrap();
3009
3010        let overlays = &result.fragments[0].overlays;
3011        assert_eq!(overlays.len(), 2);
3012        assert_eq!(overlays[0].data_file.fields.as_ref(), [1i32].as_slice());
3013        assert_eq!(overlays[1].data_file.fields.as_ref(), [2i32].as_slice());
3014    }
3015
3016    #[test]
3017    fn test_data_overlay_build_manifest_rejects_unknown_fragment() {
3018        let manifest = sample_manifest();
3019        let txn = Transaction::new(
3020            manifest.version,
3021            Operation::DataOverlay {
3022                groups: vec![DataOverlayGroup {
3023                    fragment_id: 99,
3024                    overlays: vec![overlay_with_field(1, 0)],
3025                }],
3026            },
3027            None,
3028        );
3029        let err = txn
3030            .build_manifest(Some(&manifest), vec![], "txn", &default_build_config())
3031            .unwrap_err();
3032        assert!(err.to_string().contains("does not exist"), "{err}");
3033    }
3034
3035    #[test]
3036    fn test_nullability_assertion_defaults_conservative() {
3037        // A writer that predates the field encodes nothing, which decodes as
3038        // false: no assertion, so a legacy tightening or required-field merge
3039        // still conflicts. Only an explicit true skips the barrier.
3040        for encoded in [false, true] {
3041            let txn = Transaction::try_from(pb::Transaction {
3042                read_version: 1,
3043                uuid: "test".to_string(),
3044                operation: Some(pb::transaction::Operation::Project(
3045                    pb::transaction::Project {
3046                        schema: vec![],
3047                        preserves_nullability: encoded,
3048                    },
3049                )),
3050                ..Default::default()
3051            })
3052            .unwrap();
3053            assert!(
3054                matches!(txn.operation, Operation::Project { preserves_nullability, .. } if preserves_nullability == encoded),
3055                "encoded={encoded:?}"
3056            );
3057
3058            let txn = Transaction::try_from(pb::Transaction {
3059                read_version: 1,
3060                uuid: "test".to_string(),
3061                operation: Some(pb::transaction::Operation::Merge(pb::transaction::Merge {
3062                    fragments: vec![],
3063                    schema: vec![],
3064                    schema_metadata: Default::default(),
3065                    preserves_nullability: encoded,
3066                })),
3067                ..Default::default()
3068            })
3069            .unwrap();
3070            assert!(
3071                matches!(txn.operation, Operation::Merge { preserves_nullability, .. } if preserves_nullability == encoded),
3072                "encoded={encoded:?}"
3073            );
3074        }
3075    }
3076
3077    mod mem_wal_index_coverage {
3078        use super::*;
3079        use crate::system_index::mem_wal::{
3080            CompactedSsTable, IndexCatchupProgress, MEM_WAL_INDEX_NAME, MemWalIndexDetails,
3081        };
3082
3083        fn user_index(name: &str, uuid: Uuid, frags: &[u32]) -> IndexMetadata {
3084            IndexMetadata {
3085                uuid,
3086                name: name.to_string(),
3087                fields: vec![0],
3088                covering_fields: vec![],
3089                dataset_version: 1,
3090                fragment_bitmap: Some(RoaringBitmap::from_iter(frags.iter().copied())),
3091                index_details: None,
3092                index_version: 0,
3093                created_at: None,
3094                base_id: None,
3095                files: None,
3096            }
3097        }
3098
3099        fn mem_wal_index(details: MemWalIndexDetails) -> IndexMetadata {
3100            crate::system_index::mem_wal::new_mem_wal_index_meta(1, details).unwrap()
3101        }
3102
3103        fn coverage_for(indices: &[IndexMetadata], name: &str) -> Option<Vec<CompactedSsTable>> {
3104            let meta = indices
3105                .iter()
3106                .find(|idx| idx.name == MEM_WAL_INDEX_NAME)
3107                .expect("mem wal index present");
3108            load_mem_wal_index_details(meta.clone())
3109                .unwrap()
3110                .index_catchup
3111                .into_iter()
3112                .find(|entry| entry.index_name == name)
3113                .map(|entry| entry.caught_up_generations)
3114        }
3115
3116        fn compacted(shard: Uuid, generation: u64) -> Vec<CompactedSsTable> {
3117            vec![CompactedSsTable::new(shard, generation)]
3118        }
3119
3120        /// A manifest carrying exactly `frags`, standing in for the version a
3121        /// transaction read.
3122        fn manifest_with(frags: &[u32]) -> Manifest {
3123            let fragments: Vec<Fragment> =
3124                frags.iter().map(|id| Fragment::new(*id as u64)).collect();
3125            Manifest::new(
3126                LanceSchema::default(),
3127                Arc::new(fragments),
3128                DataStorageFormat::default(),
3129                Default::default(),
3130            )
3131        }
3132
3133        /// Drives the production path, so these exercise the real derivation.
3134        fn apply(
3135            after: &mut [IndexMetadata],
3136            before: &[IndexMetadata],
3137            read_frags: &[u32],
3138            read_indices: &[IndexMetadata],
3139        ) -> Result<()> {
3140            let manifest = manifest_with(read_frags);
3141            let segments_before = Transaction::logical_index_segments(before);
3142            Transaction::apply_mem_wal_index_coverage(
3143                after,
3144                &segments_before,
3145                Some(ReadVersionState {
3146                    manifest: &manifest,
3147                    indices: read_indices,
3148                }),
3149                2,
3150            )
3151        }
3152
3153        fn table(idx_frags: &[u32], uuid: Uuid, details: MemWalIndexDetails) -> Vec<IndexMetadata> {
3154            vec![user_index("idx", uuid, idx_frags), mem_wal_index(details)]
3155        }
3156
3157        fn progress(shard: Uuid, generation: u64) -> MemWalIndexDetails {
3158            MemWalIndexDetails {
3159                compacted_sstables: compacted(shard, generation),
3160                ..Default::default()
3161            }
3162        }
3163
3164        fn progress_with_catchup(shard: Uuid, generation: u64, caught: u64) -> MemWalIndexDetails {
3165            MemWalIndexDetails {
3166                compacted_sstables: compacted(shard, generation),
3167                index_catchup: vec![IndexCatchupProgress::new(
3168                    "idx".to_string(),
3169                    compacted(shard, caught),
3170                )],
3171                ..Default::default()
3172            }
3173        }
3174
3175        /// An index spanning every fragment the transaction read is credited
3176        /// with what that version had compacted.
3177        #[test]
3178        fn an_index_covering_the_read_version_is_credited() {
3179            let shard = Uuid::new_v4();
3180            let read = table(&[0, 1], Uuid::new_v4(), progress(shard, 5));
3181            let mut after = table(&[0, 1], Uuid::new_v4(), progress(shard, 5));
3182            apply(&mut after, &read, &[0, 1], &read).unwrap();
3183            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 5)));
3184        }
3185
3186        /// An index short of the read version proves nothing, so it gets no
3187        /// entry -- absence reads as "not caught up".
3188        #[test]
3189        fn an_index_short_of_the_read_version_is_not_credited() {
3190            let shard = Uuid::new_v4();
3191            let read = table(&[0], Uuid::new_v4(), progress(shard, 5));
3192            let mut after = table(&[0], Uuid::new_v4(), progress(shard, 5));
3193            apply(&mut after, &read, &[0, 1], &read).unwrap();
3194            assert_eq!(coverage_for(&after, "idx"), None);
3195        }
3196
3197        /// The hazard that makes the comparison use whole metadata.
3198        ///
3199        /// `Operation::Update` prunes a segment's fragment bitmap in place when
3200        /// it touches an indexed field, keeping the same UUID. A UUID-only
3201        /// "unchanged" test carries the old position forward while the index
3202        /// covers fewer fragments, and the WAL pod then trims on a position the
3203        /// index no longer earns. Reachable from the ordinary SSTable merge.
3204        #[test]
3205        fn a_bitmap_pruned_in_place_does_not_keep_its_position() {
3206            let shard = Uuid::new_v4();
3207            let uuid = Uuid::new_v4();
3208            let before = table(&[0, 1], uuid, progress_with_catchup(shard, 5, 5));
3209            // Same UUID, fragment 1 pruned away.
3210            let mut after = table(&[0], uuid, progress_with_catchup(shard, 5, 5));
3211            apply(&mut after, &before, &[0, 1], &before).unwrap();
3212            assert_eq!(
3213                coverage_for(&after, "idx"),
3214                None,
3215                "a shrunken index kept a position it no longer earns"
3216            );
3217        }
3218
3219        /// Carrying a position forward is not the same as extending it. An
3220        /// index that has not moved still only holds the generations it caught
3221        /// up to; the compaction that has landed since is in fragments it does
3222        /// not span.
3223        #[test]
3224        fn an_unchanged_index_is_not_raised_beyond_what_it_proves() {
3225            let shard = Uuid::new_v4();
3226            let uuid = Uuid::new_v4();
3227            // Recorded at generation 2; generation 5 has since been folded in.
3228            let before = table(&[0], uuid, progress_with_catchup(shard, 5, 2));
3229            let mut after = before.clone();
3230            // Fragment 1 arrived with that compaction and this index lacks it.
3231            apply(&mut after, &before, &[0, 1], &before).unwrap();
3232            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 2)));
3233        }
3234
3235        /// A recorded position above what this commit says was compacted is
3236        /// clamped down. Nothing should produce one, but a position the base
3237        /// table cannot back would retire SSTables whose rows are nowhere.
3238        #[test]
3239        fn a_carried_position_cannot_exceed_the_committed_progress() {
3240            let shard = Uuid::new_v4();
3241            let uuid = Uuid::new_v4();
3242            let before = table(&[0], uuid, progress_with_catchup(shard, 3, 9));
3243            let mut after = before.clone();
3244            apply(&mut after, &before, &[0], &before).unwrap();
3245            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 3)));
3246        }
3247
3248        /// An unchanged index keeps what it recorded even when this commit's
3249        /// own snapshot cannot prove as much.
3250        #[test]
3251        fn an_unchanged_index_is_never_lowered() {
3252            let shard = Uuid::new_v4();
3253            let uuid = Uuid::new_v4();
3254            let before = table(&[0], uuid, progress_with_catchup(shard, 9, 9));
3255            let mut after = before.clone();
3256            apply(&mut after, &before, &[0, 1], &before).unwrap();
3257            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 9)));
3258        }
3259
3260        /// Credit never exceeds what this commit records as compacted, so a
3261        /// read version since rolled back cannot retire SSTables no live commit
3262        /// copied in.
3263        #[test]
3264        fn credit_is_capped_by_the_committed_progress() {
3265            let shard = Uuid::new_v4();
3266            let read = table(&[0], Uuid::new_v4(), progress(shard, 9));
3267            let mut after = table(&[0], Uuid::new_v4(), progress(shard, 3));
3268            apply(&mut after, &read, &[0], &read).unwrap();
3269            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 3)));
3270        }
3271
3272        /// The cap is the read version's progress, not this commit's. A
3273        /// compaction that landed while the index was being built put its rows
3274        /// in fragments this transaction never inspected, so covering
3275        /// everything it *did* read earns only what had been folded in by then.
3276        #[test]
3277        fn credit_never_reaches_past_the_read_version() {
3278            let shard = Uuid::new_v4();
3279            // Read at generation 2; generation 5 landed while this ran.
3280            let read = table(&[0], Uuid::new_v4(), progress(shard, 2));
3281            let mut after = table(&[0], Uuid::new_v4(), progress(shard, 5));
3282            apply(&mut after, &read, &[0], &read).unwrap();
3283            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 2)));
3284        }
3285
3286        /// One segment with an unknown bitmap makes the whole index unproven,
3287        /// even when its siblings happen to span everything. Coverage that
3288        /// cannot be read is not coverage that can be relied on.
3289        #[test]
3290        fn an_index_with_an_unknown_segment_is_not_credited() {
3291            let shard = Uuid::new_v4();
3292            let mut unknown = user_index("idx", Uuid::new_v4(), &[]);
3293            unknown.fragment_bitmap = None;
3294            let read = vec![
3295                user_index("idx", Uuid::new_v4(), &[0, 1]),
3296                unknown,
3297                mem_wal_index(progress(shard, 5)),
3298            ];
3299            let mut after = read.clone();
3300            apply(&mut after, &read, &[0, 1], &read).unwrap();
3301            assert_eq!(coverage_for(&after, "idx"), None);
3302        }
3303
3304        /// A dropped index has no coverage left to gate anything.
3305        #[test]
3306        fn a_dropped_index_loses_its_entry() {
3307            let shard = Uuid::new_v4();
3308            let before = table(&[0], Uuid::new_v4(), progress_with_catchup(shard, 5, 5));
3309            let mut after = vec![mem_wal_index(progress_with_catchup(shard, 5, 5))];
3310            apply(&mut after, &before, &[0], &before).unwrap();
3311            assert_eq!(coverage_for(&after, "idx"), None);
3312        }
3313
3314        /// An index created by this commit is credited if it spans the read
3315        /// version -- it was built over those fragments, so it holds their
3316        /// rows. This is what the advance model could not express: an ordinary
3317        /// build that fully covers had to throw its work away and wait.
3318        #[test]
3319        fn a_new_index_covering_the_read_version_is_credited() {
3320            let shard = Uuid::new_v4();
3321            let before = vec![mem_wal_index(progress(shard, 5))];
3322            let mut after = table(&[0], Uuid::new_v4(), progress(shard, 5));
3323            // Covers the read version, but was not there when it was read.
3324            apply(&mut after, &before, &[0], &before).unwrap();
3325            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 5)));
3326        }
3327
3328        /// A table carrying compaction progress but no catch-up entry earns one
3329        /// from an ordinary commit. This is how a table written before catch-up
3330        /// was maintained heals itself: nothing has to be run against it.
3331        #[test]
3332        fn a_table_with_no_catchup_entry_earns_one() {
3333            let shard = Uuid::new_v4();
3334            let before = table(&[0], Uuid::new_v4(), progress(shard, 5));
3335            let mut after = before.clone();
3336            apply(&mut after, &before, &[0], &before).unwrap();
3337            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 5)));
3338        }
3339
3340        /// Two shards, only one of them compacted.
3341        #[test]
3342        fn each_shard_is_credited_independently() {
3343            let merged = Uuid::new_v4();
3344            let idle = Uuid::new_v4();
3345            let details = MemWalIndexDetails {
3346                compacted_sstables: vec![
3347                    CompactedSsTable::new(merged, 4),
3348                    CompactedSsTable::new(idle, 0),
3349                ],
3350                ..Default::default()
3351            };
3352            let read = table(&[0], Uuid::new_v4(), details.clone());
3353            let mut after = table(&[0], Uuid::new_v4(), details);
3354            apply(&mut after, &read, &[0], &read).unwrap();
3355            let coverage = coverage_for(&after, "idx").expect("credited");
3356            assert_eq!(
3357                coverage
3358                    .iter()
3359                    .find(|g| g.shard_id == merged)
3360                    .map(|g| g.generation),
3361                Some(4)
3362            );
3363            assert_eq!(
3364                coverage
3365                    .iter()
3366                    .find(|g| g.shard_id == idle)
3367                    .map(|g| g.generation),
3368                Some(0)
3369            );
3370        }
3371
3372        /// Two indexes advance independently: one covering, one behind.
3373        #[test]
3374        fn indexes_are_credited_independently() {
3375            let shard = Uuid::new_v4();
3376            let read = vec![
3377                user_index("fast", Uuid::new_v4(), &[0, 1]),
3378                user_index("slow", Uuid::new_v4(), &[0]),
3379                mem_wal_index(progress(shard, 6)),
3380            ];
3381            let mut after = read.clone();
3382            apply(&mut after, &read, &[0, 1], &read).unwrap();
3383            assert_eq!(coverage_for(&after, "fast"), Some(compacted(shard, 6)));
3384            assert_eq!(coverage_for(&after, "slow"), None);
3385        }
3386
3387        /// An index whose coverage is unknown cannot be shown to cover anything.
3388        #[test]
3389        fn an_index_without_a_bitmap_is_not_credited() {
3390            let shard = Uuid::new_v4();
3391            let mut idx = user_index("idx", Uuid::new_v4(), &[0]);
3392            idx.fragment_bitmap = None;
3393            let read = vec![idx, mem_wal_index(progress(shard, 5))];
3394            let mut after = read.clone();
3395            apply(&mut after, &read, &[0], &read).unwrap();
3396            assert_eq!(coverage_for(&after, "idx"), None);
3397        }
3398
3399        /// Nothing compacted means nothing to be behind on.
3400        #[test]
3401        fn no_compaction_progress_writes_no_entries() {
3402            let before = table(&[0], Uuid::new_v4(), MemWalIndexDetails::default());
3403            let mut after = before.clone();
3404            let untouched = after.clone();
3405            apply(&mut after, &before, &[0], &before).unwrap();
3406            assert_eq!(after, untouched);
3407        }
3408
3409        /// No MemWAL system index: nothing to maintain, and no error.
3410        #[test]
3411        fn a_table_without_mem_wal_is_a_no_op() {
3412            let before = vec![user_index("idx", Uuid::new_v4(), &[0])];
3413            let mut after = before.clone();
3414            let untouched = after.clone();
3415            apply(&mut after, &before, &[0], &before).unwrap();
3416            assert_eq!(after, untouched);
3417        }
3418
3419        /// No read version -- dataset creation, detached commits -- credits
3420        /// nothing and lowers nothing.
3421        #[test]
3422        fn without_a_read_version_nothing_changes() {
3423            let shard = Uuid::new_v4();
3424            let uuid = Uuid::new_v4();
3425            let before = table(&[0], uuid, progress_with_catchup(shard, 5, 5));
3426            let mut after = before.clone();
3427            let segments_before = Transaction::logical_index_segments(&before);
3428            Transaction::apply_mem_wal_index_coverage(&mut after, &segments_before, None, 2)
3429                .unwrap();
3430            assert_eq!(coverage_for(&after, "idx"), Some(compacted(shard, 5)));
3431        }
3432
3433        /// An untrained index covers nothing that exists, so a sibling's work
3434        /// is no evidence for it.
3435        #[test]
3436        fn an_untrained_index_earns_nothing() {
3437            let shard = Uuid::new_v4();
3438            let read = vec![
3439                user_index("untrained", Uuid::new_v4(), &[]),
3440                user_index("trained", Uuid::new_v4(), &[0]),
3441                mem_wal_index(progress(shard, 10)),
3442            ];
3443            let mut after = read.clone();
3444            apply(&mut after, &read, &[0], &read).unwrap();
3445            assert_eq!(coverage_for(&after, "untrained"), None);
3446            assert_eq!(coverage_for(&after, "trained"), Some(compacted(shard, 10)));
3447        }
3448
3449        /// Shards move independently within one index: one advances on this
3450        /// commit's proof while another keeps the position it already had.
3451        #[test]
3452        fn a_shard_keeps_its_position_while_another_advances() {
3453            let (advancing, quiet) = (Uuid::new_v4(), Uuid::new_v4());
3454            let uuid = Uuid::new_v4();
3455            let details = |advancing_gen: u64| MemWalIndexDetails {
3456                compacted_sstables: vec![
3457                    CompactedSsTable::new(advancing, advancing_gen),
3458                    CompactedSsTable::new(quiet, 10),
3459                ],
3460                index_catchup: vec![IndexCatchupProgress::new(
3461                    "idx".to_string(),
3462                    vec![CompactedSsTable::new(quiet, 7)],
3463                )],
3464                ..Default::default()
3465            };
3466            // The quiet shard was never compacted as of the read, so nothing
3467            // this commit proves reaches it -- it keeps its recorded 7.
3468            let read = vec![
3469                user_index("idx", uuid, &[0]),
3470                mem_wal_index(MemWalIndexDetails {
3471                    compacted_sstables: vec![CompactedSsTable::new(advancing, 9)],
3472                    ..details(9)
3473                }),
3474            ];
3475            let mut after = vec![user_index("idx", uuid, &[0]), mem_wal_index(details(10))];
3476            apply(&mut after, &read, &[0], &read).unwrap();
3477
3478            let mut coverage = coverage_for(&after, "idx").expect("credited");
3479            coverage.sort_unstable_by_key(|sstable| sstable.shard_id);
3480            let mut expected = vec![
3481                CompactedSsTable::new(advancing, 9),
3482                CompactedSsTable::new(quiet, 7),
3483            ];
3484            expected.sort_unstable_by_key(|sstable| sstable.shard_id);
3485            assert_eq!(coverage, expected);
3486        }
3487
3488        /// The derivation drops coverage an index no longer earns, but it never
3489        /// rejects the commit -- an ordinary index job must not be blocked by
3490        /// a protocol it knows nothing about.
3491        #[test]
3492        fn an_ordinary_index_job_is_never_blocked() {
3493            let shard = Uuid::new_v4();
3494            let before = table(&[0, 1], Uuid::new_v4(), progress_with_catchup(shard, 5, 5));
3495            // Rebuilt over a subset -- the shape a partial reindex leaves.
3496            let mut after = table(&[0], Uuid::new_v4(), progress_with_catchup(shard, 5, 5));
3497            apply(&mut after, &before, &[0, 1], &before).unwrap();
3498            assert_eq!(coverage_for(&after, "idx"), None);
3499        }
3500
3501        /// A reader's rule is that a missing entry means "not caught up", so an
3502        /// index caught up to nothing must be absent rather than present at
3503        /// generation zero -- otherwise it reads as known-and-covered.
3504        #[test]
3505        fn an_index_caught_up_to_nothing_gets_no_entry() {
3506            let shard = Uuid::new_v4();
3507            let uuid = Uuid::new_v4();
3508            let before = table(&[0], uuid, progress_with_catchup(shard, 5, 0));
3509            let mut after = before.clone();
3510            // Does not span the read version, so nothing lifts it off zero.
3511            apply(&mut after, &before, &[0, 1], &before).unwrap();
3512            assert_eq!(coverage_for(&after, "idx"), None);
3513        }
3514
3515        /// Each shard carries its own position. Collapsing them to one value
3516        /// would credit a lagging shard with a busier shard's progress.
3517        #[test]
3518        fn carried_positions_do_not_leak_between_shards() {
3519            let (ahead, behind) = (Uuid::new_v4(), Uuid::new_v4());
3520            let uuid = Uuid::new_v4();
3521            let details = MemWalIndexDetails {
3522                compacted_sstables: vec![
3523                    CompactedSsTable::new(ahead, 10),
3524                    CompactedSsTable::new(behind, 10),
3525                ],
3526                index_catchup: vec![IndexCatchupProgress::new(
3527                    "idx".to_string(),
3528                    vec![
3529                        CompactedSsTable::new(ahead, 8),
3530                        CompactedSsTable::new(behind, 2),
3531                    ],
3532                )],
3533                ..Default::default()
3534            };
3535            let before = vec![user_index("idx", uuid, &[0]), mem_wal_index(details)];
3536            let mut after = before.clone();
3537            // Unchanged and unproven: both shards keep exactly what they had.
3538            apply(&mut after, &before, &[0, 1], &before).unwrap();
3539
3540            let mut coverage = coverage_for(&after, "idx").expect("carried");
3541            coverage.sort_unstable_by_key(|sstable| sstable.shard_id);
3542            let mut expected = vec![
3543                CompactedSsTable::new(ahead, 8),
3544                CompactedSsTable::new(behind, 2),
3545            ];
3546            expected.sort_unstable_by_key(|sstable| sstable.shard_id);
3547            assert_eq!(coverage, expected);
3548        }
3549
3550        /// The derivation runs while the manifest is being built, but the
3551        /// index list is not final there: `migrate_indices` recalculates a
3552        /// segment's fragment bitmap and keeps its UUID. A position decided
3553        /// before that must not survive the narrowing, or the WAL pod trims
3554        /// against an index that no longer covers those rows.
3555        #[test]
3556        fn a_bitmap_narrowed_after_the_build_loses_its_position() {
3557            let shard = Uuid::new_v4();
3558            let uuid = Uuid::new_v4();
3559            // What migrate_indices leaves behind: same UUID, fewer fragments,
3560            // and it says so.
3561            let mut migrated = table(&[0], uuid, progress_with_catchup(shard, 5, 5));
3562
3563            Transaction::withdraw_coverage_invalidated_after_build(
3564                &mut migrated,
3565                &["idx".to_string()],
3566                3,
3567            )
3568            .unwrap();
3569
3570            assert_eq!(coverage_for(&migrated, "idx"), None);
3571        }
3572
3573        /// Migration routinely fills in file lists and inferred details. Those
3574        /// do not change which rows an index answers for, so withdrawing on
3575        /// them would drop coverage every commit for no reason.
3576        #[test]
3577        fn metadata_migration_that_does_not_narrow_keeps_its_position() {
3578            let shard = Uuid::new_v4();
3579            let uuid = Uuid::new_v4();
3580            let mut migrated = table(&[0, 1], uuid, progress_with_catchup(shard, 5, 5));
3581            migrated[0].files = Some(Vec::new());
3582            migrated[0].created_at = Some(chrono::Utc::now());
3583
3584            // Nothing narrowed, so migration reports nothing.
3585            Transaction::withdraw_coverage_invalidated_after_build(&mut migrated, &[], 3).unwrap();
3586
3587            assert_eq!(coverage_for(&migrated, "idx"), Some(compacted(shard, 5)));
3588        }
3589
3590        /// A commit that changes nothing must not churn the system index: a new
3591        /// UUID on every append would invalidate its cache entry fleet-wide.
3592        #[test]
3593        fn an_unchanged_commit_does_not_rewrite_the_system_index() {
3594            let shard = Uuid::new_v4();
3595            let uuid = Uuid::new_v4();
3596            let before = table(&[0], uuid, progress_with_catchup(shard, 5, 5));
3597            let mut after = before.clone();
3598            apply(&mut after, &before, &[0], &before).unwrap();
3599
3600            let system_uuid = |indices: &[IndexMetadata]| {
3601                indices
3602                    .iter()
3603                    .find(|idx| idx.name == MEM_WAL_INDEX_NAME)
3604                    .unwrap()
3605                    .uuid
3606            };
3607            assert_eq!(system_uuid(&after), system_uuid(&before));
3608        }
3609
3610        /// A commit with no read version still withdraws. It can prove nothing,
3611        /// so an index it changed keeps no position -- the alternative leaves a
3612        /// position describing an index that no longer exists.
3613        #[test]
3614        fn without_a_read_version_a_changed_index_still_loses_its_position() {
3615            let shard = Uuid::new_v4();
3616            let before = table(&[0, 1], Uuid::new_v4(), progress_with_catchup(shard, 5, 5));
3617            let mut after = table(&[0], Uuid::new_v4(), progress_with_catchup(shard, 5, 5));
3618            let segments_before = Transaction::logical_index_segments(&before);
3619            Transaction::apply_mem_wal_index_coverage(&mut after, &segments_before, None, 2)
3620                .unwrap();
3621            assert_eq!(coverage_for(&after, "idx"), None);
3622        }
3623
3624        /// Two attempts against the same read version agree, which is what makes
3625        /// a rebase safe: `read_version` is fixed for a transaction's life.
3626        #[test]
3627        fn the_derivation_is_stable_across_attempts() {
3628            let shard = Uuid::new_v4();
3629            let read = table(&[0, 1], Uuid::new_v4(), progress(shard, 5));
3630            let mut first = table(&[0, 1], Uuid::new_v4(), progress(shard, 5));
3631            let mut second = first.clone();
3632            apply(&mut first, &read, &[0, 1], &read).unwrap();
3633            apply(&mut second, &read, &[0, 1], &read).unwrap();
3634            assert_eq!(coverage_for(&first, "idx"), coverage_for(&second, "idx"));
3635        }
3636    }
3637}