From a1fbbed7448803631e6d0021b4b63bb998250308 Mon Sep 17 00:00:00 2001 From: Beinan Date: Fri, 31 Jul 2026 18:31:12 +0000 Subject: [PATCH] fix(core): use single-scan rollout pagination --- .../lance-context-core/src/rollout_store.rs | 295 ++++++++++-------- 1 file changed, 167 insertions(+), 128 deletions(-) diff --git a/crates/lance-context-core/src/rollout_store.rs b/crates/lance-context-core/src/rollout_store.rs index f5b0954..c724161 100644 --- a/crates/lance-context-core/src/rollout_store.rs +++ b/crates/lance-context-core/src/rollout_store.rs @@ -66,6 +66,7 @@ use datafusion::sql::sqlparser::ast::Statement as SqlStatement; use futures::{stream, StreamExt, TryStreamExt}; use lance::dataset::mem_wal::{LsmScanner, ShardManifestStore, ShardSnapshot}; use lance::dataset::optimize::CompactionMetrics; +use lance::dataset::scanner::MaterializationStyle; use lance::dataset::Dataset; use lance::session::Session; use lance::{Error as LanceError, Result as LanceResult}; @@ -97,6 +98,16 @@ const CLAIM_CHECK_COLUMNS: [&str; 5] = [ "user_metadata", ]; +const PAGINATION_LATE_COLUMNS: [&str; 7] = [ + "content", + "model_input_string", + "model_output_string", + "rationale", + "problem_text", + "user_metadata", + "metadata", +]; + /// Read-only observability snapshot of a rollout store. /// /// Produced by [`RolloutStore::observe`] from base-table and MemWAL metadata. @@ -737,16 +748,10 @@ impl RolloutStore { /// Filter and page rollout rows from a chosen [`ListSource`]. /// /// Reads one row beyond the requested page to report `has_more`, avoiding - /// an unbounded full-table count on every UI request. Pagination is - /// deliberately late-materialized in two scans: - /// - /// 1. scan, sort, and deduplicate only `id` to select the page; - /// 2. fetch the complete non-blob columns for those page ids in one query. - /// - /// [`LsmScanner`] sorts every source by primary key before applying its - /// global limit. Keeping wide token/logprob/metadata columns out of that - /// full-source sort makes browsing large rollout tables substantially - /// cheaper while preserving the same LSM deduplication semantics. + /// an unbounded full-table count on every UI request. Each source is read in + /// one projected, filtered, bounded scan. In particular, fragments use the + /// base [`Dataset`] scanner directly so Lance can push limit/offset into the + /// scan instead of sorting the full projection through [`LsmScanner`]. /// /// [`ListSource::Fragments`] skips MemWAL manifest discovery entirely, so its /// latency is independent of how far the merge backlog has grown. @@ -757,56 +762,56 @@ impl RolloutStore { offset: usize, source: ListSource, ) -> LanceResult { - // Fragments never touches the WAL, so skip the per-shard manifest reads. - let shard_snapshots = match source { - ListSource::Fragments => Vec::new(), - ListSource::Wal | ListSource::All => self.wal_shard_snapshots().await?, - }; let filter = filters.expression(); - - let mut page_scanner = self - .lsm_scanner_for_source(source, shard_snapshots.clone()) - .project(&["id"]); - if let Some(filter) = &filter { - page_scanner = page_scanner.filter(filter)?; - } - page_scanner = page_scanner.limit(limit.saturating_add(1), Some(offset)); - - let mut stream = page_scanner.try_into_stream().await?; - let mut page_ids = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let ids = column_as::(&batch, "id")?; - page_ids.extend((0..batch.num_rows()).map(|row| ids.value(row).to_string())); - } - let has_more = page_ids.len() > limit; - page_ids.truncate(limit); - if page_ids.is_empty() { - return Ok(RolloutPage { - records: Vec::new(), - has_more, - }); - } - let columns = self.non_blob_columns(); let refs: Vec<&str> = columns.iter().map(String::as_str).collect(); - let id_refs: Vec<&str> = page_ids.iter().map(String::as_str).collect(); - let id_filter = format!("id IN ({})", sql_quoted_list(&id_refs)); - let record_scanner = self - .lsm_scanner_for_source(source, shard_snapshots) - .project(&refs) - .filter(&id_filter)?; + let page_limit = limit.saturating_add(1); - let mut stream = record_scanner.try_into_stream().await?; - let mut records_by_id = HashMap::with_capacity(page_ids.len()); - while let Some(batch) = stream.try_next().await? { - for record in batch_to_rollout_records(&batch)? { - records_by_id.insert(record.id.clone(), record); + let mut stream: datafusion::physical_plan::SendableRecordBatchStream = match source { + ListSource::Fragments => { + let scan_limit = i64::try_from(page_limit).map_err(|_| { + LanceError::from(ArrowError::InvalidArgumentError( + "pagination limit exceeds i64::MAX".to_string(), + )) + })?; + let scan_offset = i64::try_from(offset).map_err(|_| { + LanceError::from(ArrowError::InvalidArgumentError( + "pagination offset exceeds i64::MAX".to_string(), + )) + })?; + let mut scanner = self.base.dataset.scan(); + scanner.project(&refs)?; + // Lance 7's late take path can panic on nested list columns. + // Keep those early while deferring only potentially large text. + scanner.materialization_style(MaterializationStyle::all_early_except( + &PAGINATION_LATE_COLUMNS, + self.base.dataset.schema(), + )?); + if let Some(filter) = &filter { + scanner.filter(filter)?; + } + scanner.limit(Some(scan_limit), Some(scan_offset))?; + scanner.try_into_stream().await?.into() } + ListSource::Wal | ListSource::All => { + let shard_snapshots = self.wal_shard_snapshots().await?; + let mut scanner = self + .lsm_scanner_for_source(source, shard_snapshots) + .project(&refs); + if let Some(filter) = &filter { + scanner = scanner.filter(filter)?; + } + scanner = scanner.limit(page_limit, Some(offset)); + scanner.try_into_stream().await? + } + }; + + let mut records = Vec::with_capacity(page_limit); + while let Some(batch) = stream.try_next().await? { + records.extend(batch_to_rollout_records(&batch)?); } - let records = page_ids - .into_iter() - .filter_map(|id| records_by_id.remove(&id)) - .collect(); + let has_more = records.len() > limit; + records.truncate(limit); Ok(RolloutPage { records, has_more }) } @@ -1805,14 +1810,6 @@ fn optional_i8_list(array: Option<&ListArray>, row: usize) -> LanceResult