From 392a6ed2cc51490cf1c65c3a8896e1fbe48c6c9f Mon Sep 17 00:00:00 2001 From: ChilePiquin Date: Mon, 31 Aug 2026 13:22:40 -0700 Subject: [PATCH] feat(index): support create-only index requests --- docs/openapi.yml | 68 +++++++++++++++++++++ rust/lancedb/src/database/namespace.rs | 13 +++- rust/lancedb/src/job.rs | 30 ++++++--- rust/lancedb/src/remote/table.rs | 85 +++++++++++++++++++++++++- 4 files changed, 183 insertions(+), 13 deletions(-) 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/database/namespace.rs b/rust/lancedb/src/database/namespace.rs index 250d933f6..5ca720e85 100644 --- a/rust/lancedb/src/database/namespace.rs +++ b/rust/lancedb/src/database/namespace.rs @@ -539,9 +539,7 @@ impl Database for LanceNamespaceDatabase { self.namespace .drop_table(drop_request) .await - .map_err(|e| Error::Runtime { - message: format!("Failed to drop table: {}", e), - })?; + .map_err(|e| map_namespace_lance_error(e, name))?; Ok(()) } @@ -1495,6 +1493,15 @@ mod tests { .expect("Failed to list tables"); assert!(!table_names_after.contains(&"drop_test".to_string())); + let error = conn + .drop_table("drop_test", &["test_ns".into()]) + .await + .expect_err("dropping a missing table should fail"); + assert!( + matches!(error, Error::TableNotFound { ref name, .. } if name == "drop_test"), + "expected TableNotFound, got: {error:?}" + ); + // Verify: Cannot open dropped table let open_result = conn.open_table("drop_test").execute().await; assert!(open_result.is_err()); 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 d372a6f56..33251f669 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -491,9 +491,14 @@ impl std::fmt::Debug for RemoteTable { impl RemoteTable { async fn submit_create_index(&self, mut index: IndexBuilder) -> Result> { self.check_mutable().await?; + let route = if index.replace { + "create_index" + } else { + "create_index_if_not_exists" + }; let request = self .client - .post(&format!("/v1/table/{}/create_index/", self.identifier)); + .post(&format!("/v1/table/{}/{}/", self.identifier, route)); let column = match index.columns.len() { 0 => { @@ -527,6 +532,10 @@ impl RemoteTable { "column": canonical_column }); + if !index.replace { + body["replace"] = false.into(); + } + // Add name parameter if provided (for backwards compatibility, only include if Some) if let Some(ref name) = index.name { body["name"] = serde_json::Value::String(name.clone()); @@ -6077,6 +6086,80 @@ mod tests { } } + #[tokio::test] + async fn test_create_index_forwards_replace_false() { + let table = Table::new_with_handler("my_table", move |request| { + assert_eq!(request.method(), "POST"); + match request.url().path() { + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("a", DataType::Int32, false)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/create_index_if_not_exists/" => { + let body = request.body().unwrap().as_bytes().unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["replace"], json!(false)); + + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() + } + path => panic!("Unexpected path: {}", path), + } + }); + + table + .create_index(&["a"], Index::BTree(Default::default())) + .replace(false) + .execute() + .await + .unwrap(); + } + + #[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)); + let sent = legacy_create_request_sent.clone(); + let table = Table::new_with_handler_version( + "my_table", + semver::Version::new(0, 5, 1), + move |request| match request.url().path() { + "/v1/table/my_table/describe/" => { + let schema = Schema::new(vec![Field::new("a", DataType::Int32, false)]); + http::Response::builder() + .status(200) + .body(describe_response(&schema)) + .unwrap() + } + "/v1/table/my_table/create_index/" => { + sent.store(true, std::sync::atomic::Ordering::SeqCst); + http::Response::builder() + .status(200) + .body("{}".to_string()) + .unwrap() + } + "/v1/table/my_table/create_index_if_not_exists/" => http::Response::builder() + .status(404) + .body("not found".to_string()) + .unwrap(), + path => panic!("Unexpected path: {}", path), + }, + ); + + let result = table + .create_index(&["a"], Index::BTree(Default::default())) + .replace(false) + .execute() + .await; + + assert!(result.is_err()); + assert!(!legacy_create_request_sent.load(std::sync::atomic::Ordering::SeqCst)); + } + #[tokio::test] async fn test_create_index_returns_job() { let describe_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));