Fix index UUID review gaps

This commit is contained in:
ChilePiquin
2026-08-31 09:14:23 -07:00
parent 1c41d5c696
commit 8d1b083af2
5 changed files with 224 additions and 9 deletions
+68
View File
@@ -446,6 +446,15 @@ paths:
properties:
column:
type: string
name:
type: string
description: Optional name for the created index.
replace:
type: boolean
default: true
description: |
Whether to replace an existing index with the same resolved
name. Defaults to true.
metric_type:
type: string
nullable: false
@@ -472,6 +481,65 @@ paths:
$ref: "#/components/responses/unauthorized"
"404":
$ref: "#/components/responses/not_found"
/v1/table/{name}/create_index_if_not_exists/:
post:
description: |
Create vector index on a Table without replacing an existing index.
Servers that do not support create-only index creation may return 404
for this route.
tags:
- Tables
summary: Create vector index on a Table if it does not already exist
operationId: createIndexIfNotExists
parameters:
- $ref: "#/components/parameters/table_name"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
column:
type: string
name:
type: string
description: Optional name for the created index.
replace:
type: boolean
default: false
description: |
Must be false for create-only behavior. If an index with
the same resolved name already exists, the server returns a
conflict or invalid request error without replacing it.
metric_type:
type: string
nullable: false
description: |
The metric type to use for the index. l2, Cosine, Dot are supported.
index_type:
type: string
custom_stop_words:
type: [array, "null"]
items:
type: string
description: |
The custom stop-word list for an FTS index. A non-null
array replaces the language's built-in stop-word list and is only
applied when remove_stop_words is enabled. Null uses the built-in
language list, while an empty array explicitly replaces it with no
stop words.
responses:
"200":
description: Index successfully created, or the create-only index job was accepted.
"400":
$ref: "#/components/responses/invalid_request"
"401":
$ref: "#/components/responses/unauthorized"
"409":
description: An index with the same resolved name already exists.
"404":
$ref: "#/components/responses/not_found"
/v1/table/{name}/create_scalar_index/:
post:
description: Create a scalar index on a table
+31
View File
@@ -220,6 +220,37 @@ impl IndexBuilder {
}
/// Use a caller-selected UUID for the created index.
///
/// This is supported for native LanceDB tables. Remote tables currently
/// reject caller-selected UUIDs because the remote create-index protocol
/// does not carry index UUIDs.
///
/// The UUID must not already belong to a committed index on the table. If
/// the UUID is already in use, index creation fails before building the new
/// index. This check is independent of [`Self::replace`]: `replace(true)`
/// may replace an index name, but it may not reuse another committed
/// index's UUID.
///
/// # Examples
///
/// ```
/// use lancedb::{connect, index::{Index, scalar::BTreeIndexBuilder}};
/// use uuid::Uuid;
///
/// # async fn index_uuid_example() -> lancedb::Result<()> {
/// let db = connect("data/sample-lancedb").execute().await?;
/// let table = db.open_table("my_table").execute().await?;
/// let index_uuid = Uuid::new_v4();
///
/// table
/// .create_index(&["user_id"], Index::BTree(BTreeIndexBuilder::default()))
/// .name("user_id_btree_index".to_string())
/// .index_uuid(index_uuid)
/// .execute()
/// .await?;
/// # Ok(())
/// # }
/// ```
pub fn index_uuid(mut self, uuid: uuid::Uuid) -> Self {
self.index_uuid = Some(uuid);
self
+21 -9
View File
@@ -51,23 +51,35 @@ impl TerminalResult {
self.value.as_ref()
}
#[cfg(feature = "remote")]
fn remote_decode_error(request_id: String, message: String) -> Error {
Error::Http {
source: message.into(),
request_id,
status_code: None,
}
}
#[cfg(not(feature = "remote"))]
fn remote_decode_error(_request_id: String, message: String) -> Error {
Error::Runtime { message }
}
fn decode<T: DeserializeOwned>(self) -> Result<T> {
let value = self.value.ok_or_else(|| match &self.request_id {
Some(request_id) => Error::Http {
source: "successful typed job response did not contain a result".into(),
request_id: request_id.clone(),
status_code: None,
},
Some(request_id) => Self::remote_decode_error(
request_id.clone(),
"successful typed job response did not contain a result".to_string(),
),
None => Error::Runtime {
message: "successful typed job did not contain a result".to_string(),
},
})?;
serde_json::from_value(value).map_err(|error| match self.request_id {
Some(request_id) => Error::Http {
source: format!("failed to parse typed job result: {error}").into(),
Some(request_id) => Self::remote_decode_error(
request_id,
status_code: None,
},
format!("failed to parse typed job result: {error}"),
),
None => Error::Runtime {
message: format!("failed to parse typed job result: {error}"),
},
+30
View File
@@ -491,6 +491,12 @@ impl<S: HttpSend> std::fmt::Debug for RemoteTable<S> {
impl<S: HttpSend> RemoteTable<S> {
async fn submit_create_index(&self, mut index: IndexBuilder) -> Result<Option<String>> {
self.check_mutable().await?;
if index.index_uuid.is_some() {
return Err(Error::NotSupported {
message: "caller-selected index UUIDs are only supported for native LanceDB tables"
.into(),
});
}
let route = if index.replace {
"create_index"
} else {
@@ -6120,6 +6126,30 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_create_index_rejects_remote_index_uuid() {
let request_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
let sent = request_sent.clone();
let table = Table::new_with_handler("my_table", move |_request| {
sent.store(true, std::sync::atomic::Ordering::SeqCst);
http::Response::builder()
.status(500)
.body("unexpected request".to_string())
.unwrap()
});
let err = table
.create_index(&["a"], Index::BTree(Default::default()))
.index_uuid(uuid::Uuid::new_v4())
.execute()
.await
.unwrap_err();
assert!(matches!(err, Error::NotSupported { .. }));
assert!(err.to_string().contains("native LanceDB tables"));
assert!(!request_sent.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn test_create_index_replace_false_does_not_use_legacy_route_after_backend_downgrade() {
let legacy_create_request_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
+74
View File
@@ -151,6 +151,19 @@ impl NativeTable {
let (column, lance_idx_params, index_type) = prepared;
let mut dataset = (*self.dataset.get().await?).clone();
let columns = [column.as_str()];
if let Some(index_uuid) = opts.index_uuid {
let indices = dataset.load_indices().await?;
if let Some(existing_index) = indices.iter().find(|index| index.uuid == index_uuid) {
return Err(Error::InvalidInput {
message: format!(
"Index UUID '{}' is already used by index '{}'",
index_uuid, existing_index.name
),
});
}
}
let mut builder = dataset
.create_index_builder(&columns, index_type, lance_idx_params.as_ref())
.train(opts.train)
@@ -1070,6 +1083,67 @@ mod tests {
assert_eq!(stats.num_unindexed_rows, 1);
}
#[tokio::test]
async fn test_create_index_uses_selected_uuid() {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(("i", Int32, [1])).unwrap();
let table = conn
.create_table("my_table", batch)
.execute()
.await
.unwrap();
let index_uuid = uuid::Uuid::new_v4();
table
.create_index(&["i"], Index::BTree(BTreeIndexBuilder::default()))
.name("i_idx".to_string())
.index_uuid(index_uuid)
.execute()
.await
.unwrap();
let index_configs = table.list_indices().await.unwrap();
assert_eq!(index_configs.len(), 1);
let index = index_configs.into_iter().next().unwrap();
assert_eq!(index.name, "i_idx");
assert_eq!(index.index_uuid, Some(index_uuid.to_string()));
}
#[tokio::test]
async fn test_create_index_rejects_selected_uuid_collision() {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(("a", Int32, [1]), ("b", Int32, [2])).unwrap();
let table = conn
.create_table("my_table", batch)
.execute()
.await
.unwrap();
let index_uuid = uuid::Uuid::new_v4();
table
.create_index(&["a"], Index::BTree(BTreeIndexBuilder::default()))
.name("a_idx".to_string())
.index_uuid(index_uuid)
.execute()
.await
.unwrap();
let err = table
.create_index(&["b"], Index::BTree(BTreeIndexBuilder::default()))
.name("b_idx".to_string())
.index_uuid(index_uuid)
.execute()
.await
.unwrap_err();
assert!(err.to_string().contains("already used by index 'a_idx'"));
let index_configs = table.list_indices().await.unwrap();
assert_eq!(index_configs.len(), 1);
let index = index_configs.into_iter().next().unwrap();
assert_eq!(index.name, "a_idx");
assert_eq!(index.index_uuid, Some(index_uuid.to_string()));
}
#[tokio::test]
async fn test_create_fm_index() {
let tmp_dir = tempdir().unwrap();