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