From da732d9eaec801b8eb0e8291d7c84f578aa64f30 Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Sun, 16 Aug 2026 18:01:41 -0700 Subject: [PATCH] fix: rebuild when an update rewrites a fragment newer than the watermark An update rewrites a whole fragment. When it lands on one appended since the watermark, that fragment also holds rows the update never touched -- rows the recompute does not cover, and which the fragment's exclusion from the append set then drops. The view lost them while the watermark advanced past them. Co-Authored-By: Claude Fable 5 --- rust/lancedb/src/materialized_view/refresh.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/rust/lancedb/src/materialized_view/refresh.rs b/rust/lancedb/src/materialized_view/refresh.rs index c398fbd8b..eda09e3fc 100644 --- a/rust/lancedb/src/materialized_view/refresh.rs +++ b/rust/lancedb/src/materialized_view/refresh.rs @@ -79,8 +79,9 @@ pub enum RefreshMode { pub struct RefreshMaterializedViewResult { /// How the view was brought up to date. pub mode: RefreshMode, - /// Rows written to the view: everything on a rebuild, the appended rows - /// on an incremental refresh. + /// Rows written to the view: everything on a rebuild, and on an + /// incremental refresh both the rows added and the rows recomputed in + /// place of ones the source changed. pub rows_written: u64, /// The source table version the view now reflects. pub source_version: u64, @@ -316,6 +317,17 @@ async fn plan_increment( if folded { return None; } + // An update rewrites a whole fragment. If it touched one the watermark + // never saw, that fragment holds rows appended since -- new rows the + // update did not change, which the recompute does not cover and which + // this fragment's exclusion from the append set would drop. + if delta + .updated_in_place + .iter() + .any(|id| !old_ids.contains(id)) + { + return None; + } // A cap stops rows being materialized once the view is full, and the // watermark then advances past them. Eviction can free room those // rows should fill, but they are no longer in any delta, so a capped @@ -378,6 +390,8 @@ struct TxnDelta { /// The delta changed source rows in place, so the view holds rows to /// recompute. updated_rows: bool, + /// Fragments an update modified in place, as opposed to produced. + updated_in_place: HashSet, } /// Read the delta from the transaction log, `None` where it holds anything @@ -395,6 +409,7 @@ async fn appends_and_rewrites(cur: &Dataset, from: u64, to: u64) -> Option Option { @@ -1559,6 +1577,35 @@ mod tests { ); } + /// An update rewrites a whole fragment. When it lands on one appended + /// since the watermark, that fragment also holds rows the update never + /// touched -- rows the recompute does not cover and the append set no + /// longer reaches. + #[tokio::test] + async fn test_update_touching_a_new_fragment_keeps_its_untouched_rows() { + let (conn, source) = db_with_source(vec![1, 2]).await; + let view = doubled_view(&conn).await; + view.refresh().execute().await.unwrap(); + + // One fragment, appended after the watermark, holding both a row the + // update will change and a row it will not. + append(&source, vec![3, 40]).await; + source + .update() + .column("x", "99") + .only_if("x = 3") + .execute() + .await + .unwrap(); + + view.refresh().execute().await.unwrap(); + assert_eq!( + read(view.table(), "twice").await, + vec![2, 4, 80, 198], + "a row appended into the updated fragment went missing" + ); + } + async fn compact(source: &Table) { source .optimize(OptimizeAction::Compact {