diff --git a/docs/openapi.yml b/docs/openapi.yml index 2f9ae7d99..1421b6226 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -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 diff --git a/rust/lancedb/src/index.rs b/rust/lancedb/src/index.rs index 8017c5ebe..45d21b14b 100644 --- a/rust/lancedb/src/index.rs +++ b/rust/lancedb/src/index.rs @@ -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 diff --git a/rust/lancedb/src/job.rs b/rust/lancedb/src/job.rs index 22f1a0450..a89c7667f 100644 --- a/rust/lancedb/src/job.rs +++ b/rust/lancedb/src/job.rs @@ -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(self) -> Result { 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}"), }, diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 33251f669..cbb4f79e4 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -491,6 +491,12 @@ impl std::fmt::Debug for RemoteTable { impl RemoteTable { async fn submit_create_index(&self, mut index: IndexBuilder) -> Result> { 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)); diff --git a/rust/lancedb/src/table/create_index.rs b/rust/lancedb/src/table/create_index.rs index b47ebf6f6..8a00ee992 100644 --- a/rust/lancedb/src/table/create_index.rs +++ b/rust/lancedb/src/table/create_index.rs @@ -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();