feat(sql): expose composable native vector relations (#4340)

## Summary

Native vector dedup currently materializes through the native MV refresh
protocol. Add composable DataFusion pair and retained-row relations so a
host can use its existing SQL job and MV writer instead.

`VectorQueryTable` binds a retained snapshot, selects all/first/random
nonempty IVF scopes before dispatch, and exposes `DuplicatePairsExec` as
its physical child. Pair queries can include segment, partition and
snapshot columns and fetch a scalar application key for each endpoint
without reading source vectors for scoring. The default primitive schema
remains unchanged.

`VectorDedupExec` consumes the pair stream, reuses the spillable greedy
direct-representative selection, and scans retained original rows from
that snapshot. A–B–C without A–C retains A and C. This query path has no
durable partition receipts or writes; publication and failure belong to
the enclosing SQL sink. Existing native MV APIs remain available.

`BaseTableAdapter::vector_query_snapshot(None)` captures latest without
changing a shared table handle's pin; `Some(version)` selects a retained
version. A host can retain unbound SQL and resolve latest again for each
refresh. Single-segment random sampling matches Sophon's `ivf_partition`
SplitMix64 policy; multi-segment sampling ranks canonical nonempty
scopes.

Host integration and SQL syntax:
https://github.com/lancedb/sophon/pull/8135. The host authorizes
sources, binds named arguments, distributes the pair child, and uses its
existing SQL Job Registry and atomic MV replacement.

## Validation

All manual validation ran on an AWS r8i.8xlarge VM, not the developer
laptop.

- Focused Rust integration suite: 2 passed, including multiple
segments/partitions, empty results, threshold boundaries, 8,193 rows per
segment, deterministic sampling, mapped application IDs, and
native-oracle pair equality.
- Additional compacted stable/nonstable row-ID and blob test: 1 passed,
including application-key mapping, null vectors, snapshot deletions and
explicit/latest version capture.
- Rust formatting and focused Clippy with warnings denied passed.
- Dependent Sophon SQL-job E2E passes with two worker PEs: pairs,
sampling, direct representatives, latest/fixed snapshot refreshes, and
blob payloads. Killing a SQL worker after partial staging preserves the
old MV; full rerun commits the complete replacement. Performance
measurement is in progress in the dependent PR; no performance claim is
made here.
This commit is contained in:
Yang Cen
2026-09-29 13:24:21 +08:00
committed by GitHub
parent 8e3606fbe3
commit b8af687286
6 changed files with 664 additions and 9 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ mod duplicate_pairs;
mod grouped;
mod grouped_units;
mod partitioned;
mod vector_dedup;
pub(crate) mod vector_dedup;
pub use duplicate_pairs::{VectorSource, VectorSourceKind};
pub use grouped::IVF_PARTITION;
/// Refreshing a view grouped by [`IVF_PARTITION`] in units, one per index
@@ -156,7 +156,7 @@ fn edge_schema() -> SchemaRef {
/// Sorting canonical edges makes the first unremoved endpoint the smallest
/// unassigned representative. The sort spills; only vertex masks stay in RAM.
async fn select_representatives(
pub async fn select_representatives(
stream: SendableRecordBatchStream,
memory: usize,
) -> Result<(RoaringTreemap, RoaringTreemap)> {
@@ -6,3 +6,5 @@
pub mod fts;
pub mod duplicate_pairs;
pub mod vector_query;
@@ -330,6 +330,96 @@ pub async fn plan_duplicate_pairs(
Ok(tasks)
}
/// Optional query columns. The default preserves the primitive's three-column
/// schema; SQL convenience calls include physical scope identity and may map
/// endpoints to an application key without fetching source vectors.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DuplicatePairsOutput {
pub scope: bool,
pub id_column: Option<String>,
}
impl DuplicatePairsOutput {
pub fn schema(&self, dataset: &Dataset) -> Result<SchemaRef> {
let mut fields = duplicate_pair_schema().fields().to_vec();
if self.scope {
fields.extend([
Arc::new(Field::new("segment_id", DataType::Utf8, false)),
Arc::new(Field::new("partition_id", DataType::UInt64, false)),
Arc::new(Field::new("dataset_version", DataType::UInt64, false)),
]);
}
if let Some(column) = &self.id_column {
let schema = Schema::from(dataset.schema());
let field = schema.field_with_name(column)?;
if column.contains('.')
|| matches!(
field.data_type(),
DataType::FixedSizeList(..)
| DataType::List(..)
| DataType::LargeList(..)
| DataType::Struct(..)
| DataType::Map(..)
)
{
return plan_err!("id_column must be a top-level scalar column");
}
fields.push(Arc::new(Field::new(
"id_a",
field.data_type().clone(),
field.is_nullable(),
)));
fields.push(Arc::new(Field::new(
"id_b",
field.data_type().clone(),
field.is_nullable(),
)));
}
Ok(Arc::new(Schema::new(fields)))
}
async fn enrich(
&self,
batch: arrow_array::RecordBatch,
dataset: &Dataset,
task: &DuplicatePairTask,
) -> Result<arrow_array::RecordBatch> {
use arrow_array::{StringArray, UInt64Array};
let mut columns = batch.columns().to_vec();
let n = batch.num_rows();
if self.scope {
columns.extend([
Arc::new(StringArray::from(vec![task.segment_id.to_string(); n]))
as arrow_array::ArrayRef,
Arc::new(UInt64Array::from(vec![task.partition_id as u64; n])),
Arc::new(UInt64Array::from(vec![dataset.version().version; n])),
]);
}
if let Some(column) = &self.id_column {
for endpoint in 0..2 {
let ids = batch
.column(endpoint)
.as_any()
.downcast_ref::<UInt64Array>()
.expect("native row IDs");
let projection = lance::dataset::ProjectionRequest::from_columns(
[column.as_str()],
dataset.schema(),
);
let values = dataset.take_rows(ids.values(), projection).await?;
if values.num_rows() != n {
return plan_err!("pair row ID is missing from the pinned snapshot");
}
columns.push(values.column(0).clone());
}
}
Ok(arrow_array::RecordBatch::try_new(
self.schema(dataset)?,
columns,
)?)
}
}
/// Streaming native operator over partition descriptors, never source-vector
/// rows. Assigned descriptors are consumed sequentially; distributed hosts send
/// one descriptor per worker request. No Python or per-vector ANN calls.
@@ -341,6 +431,7 @@ pub struct DuplicatePairsExec {
tasks: Vec<DuplicatePairTask>,
properties: Arc<PlanProperties>,
metrics: ExecutionPlanMetricsSet,
output: DuplicatePairsOutput,
}
impl DuplicatePairsExec {
@@ -374,8 +465,23 @@ impl DuplicatePairsExec {
tasks,
properties,
metrics: ExecutionPlanMetricsSet::new(),
output: DuplicatePairsOutput::default(),
})
}
/// Configure optional output fields before dispatching this plan.
pub fn with_output(mut self, output: DuplicatePairsOutput) -> Result<Self> {
self.properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(output.schema(&self.dataset)?),
Partitioning::UnknownPartitioning(1),
EmissionType::Incremental,
Boundedness::Bounded,
));
self.output = output;
Ok(self)
}
pub fn output(&self) -> &DuplicatePairsOutput {
&self.output
}
pub fn dataset(&self) -> &Arc<Dataset> {
&self.dataset
}
@@ -430,6 +536,7 @@ impl ExecutionPlan for DuplicatePairsExec {
if partition != 0 {
return plan_err!("duplicate-pair output partition out of bounds");
}
let output = self.output.clone();
let tasks = self.tasks.clone();
let dataset = self.dataset.clone();
let config = self.config.clone();
@@ -467,6 +574,7 @@ impl ExecutionPlan for DuplicatePairsExec {
// into one-task PE requests before execution.
let stream = stream::iter(tasks)
.then(move |task| {
let output = output.clone();
let dataset = dataset.clone();
let config = config.clone();
let reservation = reservation.clone();
@@ -474,7 +582,7 @@ impl ExecutionPlan for DuplicatePairsExec {
let completed = completed.clone();
async move {
let reader = lance::index::vector::dedup::find_duplicate_pairs_in_partition_with_options(
dataset,
dataset.clone(),
&config.column,
task.segment_id,
task.partition_id,
@@ -506,15 +614,19 @@ impl ExecutionPlan for DuplicatePairsExec {
}
},
);
Ok::<_, DataFusionError>(Box::pin(RecordBatchStreamAdapter::new(
duplicate_pair_schema(),
measured,
)) as SendableRecordBatchStream)
let schema = output.schema(&dataset)?;
let measured = measured.and_then(move |batch| {
let output = output.clone();
let dataset = dataset.clone();
let task = task.clone();
async move { output.enrich(batch, &dataset, &task).await }
});
Ok::<_, DataFusionError>(Box::pin(RecordBatchStreamAdapter::new(schema, measured)) as SendableRecordBatchStream)
}
})
.try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(
duplicate_pair_schema(),
self.schema(),
stream,
)))
}
@@ -0,0 +1,352 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Composable index-backed pair and dedup relations. Hosts bind a snapshot once
//! during planning, distribute the pair child, and use their ordinary SQL sink.
use std::sync::Arc;
use arrow_schema::SchemaRef;
use async_trait::async_trait;
use datafusion_catalog::{Session, TableProvider};
use datafusion_common::{Result, plan_err};
use datafusion_execution::{SendableRecordBatchStream, TaskContext};
use datafusion_expr::{Expr, TableType};
use datafusion_physical_expr::{EquivalenceProperties, Partitioning};
use datafusion_physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
coalesce_partitions::CoalescePartitionsExec,
empty::EmptyExec,
execution_plan::{Boundedness, EmissionType},
projection::ProjectionExec,
stream::RecordBatchStreamAdapter,
};
use futures::{TryStreamExt, stream};
use lance::dataset::scanner::{RowAddrMask, RowAddrTreeMap};
use lance::{Dataset, index::DatasetIndexInternalExt};
use lance_core::datatypes::BlobHandling;
use lance_index::metrics::NoOpMetricsCollector;
use super::duplicate_pairs::{
DuplicatePairTask, DuplicatePairsConfig, DuplicatePairsExec, DuplicatePairsOutput,
plan_duplicate_pairs,
};
use crate::table::{NativeTableExt, datafusion::BaseTableAdapter};
const REPRESENTATIVE_SORT_MEMORY_BYTES: usize = 256 * 1024 * 1024;
const MATERIALIZE_BATCH_ROWS: usize = 1024;
/// Select nonempty physical IVF scopes before dispatch, never by filtering the
/// resulting pairs. A count spans all segments; partition IDs are segment-local.
#[derive(Debug, Clone, Default)]
pub enum PartitionSelection {
#[default]
All,
First {
count: usize,
},
Random {
count: usize,
seed: u64,
},
}
/// Query-only options. Snapshot binding is separate so multiple calls in one
/// statement can share the same immutable dataset.
#[derive(Debug, Clone)]
pub struct VectorQueryOptions {
pub column: String,
pub distance_threshold: f32,
pub selection: PartitionSelection,
pub output: DuplicatePairsOutput,
pub dedup: bool,
}
impl BaseTableAdapter {
/// Open a retained version, or refresh and capture the latest manifest once.
/// No worker is allowed to independently resolve "latest".
pub async fn vector_query_snapshot(&self, version: Option<u64>) -> Result<Arc<Dataset>> {
let native = self.table.as_native().ok_or_else(|| {
datafusion_common::DataFusionError::Plan(
"vector queries require a native indexed table".into(),
)
})?;
if version == Some(0) {
return plan_err!("dataset_version must be positive");
}
let current = native
.dataset
.get()
.await
.map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?;
Ok(match version {
Some(version) => Arc::new(current.checkout_version(version).await?),
None => {
// Do not change a shared table handle's pinned version.
let mut latest = (*current).clone();
latest.checkout_latest().await?;
Arc::new(latest)
}
})
}
}
/// A table provider bound to one source snapshot. The host retains the user's
/// unbound SQL for subsequent refreshes; this provider lives only for this run.
#[derive(Debug)]
pub struct VectorQueryTable {
dataset: Arc<Dataset>,
config: DuplicatePairsConfig,
tasks: Vec<DuplicatePairTask>,
options: VectorQueryOptions,
schema: SchemaRef,
}
impl VectorQueryTable {
pub async fn try_new(dataset: Arc<Dataset>, options: VectorQueryOptions) -> Result<Self> {
if options.dedup
&& (!matches!(options.selection, PartitionSelection::All)
|| options.output.id_column.is_some())
{
return plan_err!(
"vector_dedup requires all partitions and returns original rows; sampling and id_column are pair-query options"
);
}
let config = DuplicatePairsConfig {
dataset_version: dataset.version().version,
column: options.column.clone(),
distance_threshold: options.distance_threshold,
};
let mut tasks = plan_duplicate_pairs(dataset.clone(), &config).await?;
// Canonical scope order is independent of metadata enumeration order.
tasks.sort_by_key(|task| (task.segment_id, task.partition_id));
let mut nonempty = Vec::new();
for group in tasks.chunk_by(|a, b| a.segment_id == b.segment_id) {
let index = dataset
.open_vector_index(&config.column, &group[0].segment_id, &NoOpMetricsCollector)
.await?;
nonempty.extend(
group
.iter()
.filter(|task| index.partition_size(task.partition_id) > 0)
.cloned(),
);
}
match options.selection {
PartitionSelection::All => {}
PartitionSelection::First { count } | PartitionSelection::Random { count, .. }
if count == 0 =>
{
return plan_err!("partition count must be positive");
}
PartitionSelection::First { count } => nonempty.truncate(count),
PartitionSelection::Random { count, seed } => {
// A single segment matches ivf_partition's SplitMix64 sampling.
// With multiple segments the canonical scope ordinal extends the
// same policy without conflating equal partition IDs.
let segments = tasks
.iter()
.map(|t| t.segment_id)
.collect::<std::collections::BTreeSet<_>>()
.len();
let mut ranked = nonempty
.into_iter()
.enumerate()
.map(|(ordinal, task)| {
let key = if segments == 1 {
task.partition_id as u64
} else {
ordinal as u64
};
(sample_rank(seed, key), task)
})
.collect::<Vec<_>>();
ranked.sort_by_key(|(rank, task)| (*rank, task.segment_id, task.partition_id));
ranked.truncate(count);
nonempty = ranked.into_iter().map(|(_, task)| task).collect();
nonempty.sort_by_key(|task| (task.segment_id, task.partition_id));
}
}
let schema = if options.dedup {
let mut scanner = dataset.scan();
scanner.blob_handling(BlobHandling::AllBinary);
scanner.schema().await?
} else {
options.output.schema(&dataset)?
};
Ok(Self {
dataset,
config,
tasks: nonempty,
options,
schema,
})
}
}
fn sample_rank(seed: u64, ordinal: u64) -> u64 {
let mut value = seed.wrapping_add(ordinal).wrapping_add(0x9E3779B97F4A7C15);
value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
value ^ (value >> 31)
}
#[async_trait]
impl TableProvider for VectorQueryTable {
fn schema(&self) -> SchemaRef {
self.schema.clone()
}
fn table_type(&self) -> TableType {
TableType::View
}
async fn scan(
&self,
_state: &dyn Session,
projection: Option<&Vec<usize>>,
_filters: &[Expr],
_limit: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
let output = if self.options.dedup {
DuplicatePairsOutput::default()
} else {
self.options.output.clone()
};
let pairs: Arc<dyn ExecutionPlan> = if self.tasks.is_empty() {
Arc::new(EmptyExec::new(output.schema(&self.dataset)?))
} else {
Arc::new(
DuplicatePairsExec::try_new(
self.dataset.clone(),
self.config.clone(),
self.tasks.clone(),
)?
.with_output(output)?,
)
};
let plan: Arc<dyn ExecutionPlan> = if self.options.dedup {
Arc::new(VectorDedupExec::new(
self.dataset.clone(),
pairs,
self.schema.clone(),
))
} else {
pairs
};
if let Some(indices) = projection {
let expressions: Vec<_> = indices
.iter()
.map(|&i| {
(
Arc::new(datafusion_physical_expr::expressions::Column::new(
self.schema.field(i).name(),
i,
))
as Arc<dyn datafusion_physical_expr::PhysicalExpr>,
self.schema.field(i).name().clone(),
)
})
.collect();
Ok(Arc::new(ProjectionExec::try_new(expressions, plan)?))
} else {
Ok(plan)
}
}
}
/// Greedy direct representatives over the complete pair relation. Sorting spills
/// to disk; only vertex masks are retained. This has no durable checkpoint and
/// publishes nothing: failure is handled by the enclosing query/MV transaction.
#[derive(Debug)]
pub struct VectorDedupExec {
dataset: Arc<Dataset>,
pairs: Arc<dyn ExecutionPlan>,
properties: Arc<PlanProperties>,
}
impl VectorDedupExec {
fn new(dataset: Arc<Dataset>, pairs: Arc<dyn ExecutionPlan>, schema: SchemaRef) -> Self {
Self {
dataset,
pairs,
properties: Arc::new(PlanProperties::new(
EquivalenceProperties::new(schema),
Partitioning::UnknownPartitioning(1),
EmissionType::Final,
Boundedness::Bounded,
)),
}
}
}
impl DisplayAs for VectorDedupExec {
fn fmt_as(&self, _: DisplayFormatType, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"VectorDedupExec: source_version={}, policy=direct_representative",
self.dataset.version().version
)
}
}
impl ExecutionPlan for VectorDedupExec {
fn name(&self) -> &str {
"VectorDedupExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.pairs]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
if children.len() != 1 {
return plan_err!("VectorDedupExec requires one pair input");
}
Ok(Arc::new(Self::new(
self.dataset.clone(),
children[0].clone(),
self.schema(),
)))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
if partition != 0 {
return plan_err!("invalid dedup output partition");
}
let dataset = self.dataset.clone();
let pairs = CoalescePartitionsExec::new(self.pairs.clone()).execute(0, context)?;
let stream = stream::once(async move {
let (removed, _) = crate::materialized_view::vector_dedup::select_representatives(
pairs,
REPRESENTATIVE_SORT_MEMORY_BYTES,
)
.await
.map_err(|e| datafusion_common::DataFusionError::External(Box::new(e)))?;
let mut scanner = dataset.scan();
scanner
.with_row_addr_prefilter(RowAddrMask::from_block(RowAddrTreeMap::from_iter(
removed,
)))
.blob_handling(BlobHandling::AllBinary)
.batch_size(MATERIALIZE_BATCH_ROWS)
.strict_batch_size(true);
Ok::<_, datafusion_common::DataFusionError>(
scanner
.try_into_stream()
.await?
.map_err(datafusion_common::DataFusionError::from),
)
})
.try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(
self.schema(),
stream,
)))
}
}
+190 -1
View File
@@ -76,7 +76,7 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result
.to_str()
.unwrap()
.to_string();
let n = 1025;
let n = 8193;
let vectors = (0..n * 2)
.flat_map(|i| {
let i = i % n;
@@ -196,6 +196,89 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result
);
let actual = ctx.sql(&sql).await?.collect().await?;
assert_eq!(pairs(&actual), pairs(&expected));
use lancedb::table::datafusion::udtf::{
duplicate_pairs::DuplicatePairsOutput,
vector_query::{PartitionSelection, VectorQueryOptions, VectorQueryTable},
};
let query_options = VectorQueryOptions {
column: "vector".into(),
distance_threshold: threshold,
selection: PartitionSelection::All,
output: DuplicatePairsOutput {
scope: true,
id_column: Some("id".into()),
},
dedup: false,
};
let provider = VectorQueryTable::try_new(ds.clone(), query_options.clone()).await?;
let extended = ctx.read_table(Arc::new(provider))?.collect().await?;
assert_eq!(pairs(&extended), pairs(&expected));
for batch in &extended {
let rowids = batch
.column_by_name("row_id_a")
.unwrap()
.as_any()
.downcast_ref::<UInt64Array>()
.unwrap();
let ids = batch
.column_by_name("id_a")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
for i in 0..batch.num_rows() {
let expected_id = (rowids.value(i) >> 32) as i64 * n as i64
+ (rowids.value(i) & 0xffffffff) as i64;
assert_eq!(ids.value(i), expected_id);
}
}
let mut sampled_options = query_options.clone();
sampled_options.selection = PartitionSelection::Random { count: 1, seed: 42 };
let sample = ctx
.read_table(Arc::new(
VectorQueryTable::try_new(ds.clone(), sampled_options.clone()).await?,
))?
.collect()
.await?;
let replay = ctx
.read_table(Arc::new(
VectorQueryTable::try_new(ds.clone(), sampled_options).await?,
))?
.collect()
.await?;
assert_eq!(sample, replay);
let scopes = sample
.iter()
.flat_map(|batch| {
let segments = batch
.column_by_name("segment_id")
.unwrap()
.as_any()
.downcast_ref::<arrow_array::StringArray>()
.unwrap();
let partitions = batch
.column_by_name("partition_id")
.unwrap()
.as_any()
.downcast_ref::<UInt64Array>()
.unwrap();
(0..batch.num_rows())
.map(|i| (segments.value(i).to_string(), partitions.value(i)))
.collect::<Vec<_>>()
})
.collect::<std::collections::BTreeSet<_>>();
assert!(scopes.len() <= 1);
let mut dedup_options = query_options;
dedup_options.dedup = true;
dedup_options.output = DuplicatePairsOutput::default();
let clean_query = ctx
.read_table(Arc::new(
VectorQueryTable::try_new(ds.clone(), dedup_options).await?,
))?
.select(vec![datafusion::prelude::col("id")])?
.collect()
.await?;
assert_eq!(
pairs(&actual).len(),
match threshold {
@@ -388,6 +471,21 @@ async fn sql_pairs_match_native_snapshot_and_partition_tasks() -> anyhow::Result
.collect::<Vec<_>>();
actual_ids.sort_unstable();
expected_ids.sort_unstable();
let mut query_ids = clean_query
.iter()
.flat_map(|batch| {
batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec()
})
.collect::<Vec<_>>();
query_ids.sort_unstable();
assert_eq!(query_ids, expected_ids);
assert_eq!(actual_ids, expected_ids);
if threshold == 1.0 {
assert!(actual_ids.contains(&0) && actual_ids.contains(&2));
@@ -510,6 +608,90 @@ async fn dedup_materializes_blob_payloads_null_vectors_and_snapshot_deletions()
)
.await?;
let version = ds.version().version;
use lancedb::table::datafusion::udtf::{
duplicate_pairs::DuplicatePairsOutput,
vector_query::{PartitionSelection, VectorQueryOptions, VectorQueryTable},
};
let options = VectorQueryOptions {
column: "vector".into(),
distance_threshold: 1.0,
selection: PartitionSelection::All,
output: DuplicatePairsOutput::default(),
dedup: true,
};
let mapped = VectorQueryTable::try_new(
Arc::new(ds.clone()),
VectorQueryOptions {
dedup: false,
output: DuplicatePairsOutput {
scope: true,
id_column: Some("id".into()),
},
..options.clone()
},
)
.await?;
let mapped = SessionContext::new()
.read_table(Arc::new(mapped))?
.collect()
.await?;
let mut mapped_edges = Vec::new();
for batch in mapped {
let a = batch
.column_by_name("id_a")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
let b = batch
.column_by_name("id_b")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
for i in 0..batch.num_rows() {
mapped_edges.push((a.value(i).min(b.value(i)), a.value(i).max(b.value(i))));
}
}
mapped_edges.sort_unstable();
assert_eq!(mapped_edges, [(0, 1), (1, 2)]);
let provider = VectorQueryTable::try_new(Arc::new(ds.clone()), options).await?;
let query_rows = SessionContext::new()
.read_table(Arc::new(provider))?
.collect()
.await?;
let mut query_ids = query_rows
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec()
})
.collect::<Vec<_>>();
query_ids.sort_unstable();
assert_eq!(query_ids, [0, 2, 3, 4]);
for batch in &query_rows {
let ids = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
let images = batch
.column_by_name("image")
.unwrap()
.as_any()
.downcast_ref::<LargeBinaryArray>()
.unwrap();
for i in 0..batch.num_rows() {
assert_eq!(images.value(i), vec![ids.value(i) as u8; 4096]);
}
}
let definition = MaterializedViewDefinition::from_sql(&format!(
"SELECT * FROM vector_dedup('source', {version}, 'vector', 1)"
))?;
@@ -519,6 +701,13 @@ async fn dedup_materializes_blob_payloads_null_vectors_and_snapshot_deletions()
.await?;
// A later source delete does not change the declared result snapshot.
table.delete("id = 0").await?;
let adapter = BaseTableAdapter::try_new(table.base_table().clone()).await?;
let explicit = adapter.vector_query_snapshot(Some(version)).await?;
let latest = adapter.vector_query_snapshot(None).await?;
assert_eq!(explicit.version().version, version);
assert!(latest.version().version > version);
assert_eq!(explicit.count_rows(None).await?, 5);
assert_eq!(latest.count_rows(None).await?, 4);
clean.refresh().execute().await?;
assert_eq!(clean.table().count_rows(None).await?, 4);
let output = Dataset::open(&clean.table().uri().await?).await?;