feat(index): support create-only index requests

This commit is contained in:
ChilePiquin
2026-08-31 17:28:03 -07:00
parent e773d1e093
commit 392a6ed2cc
4 changed files with 183 additions and 13 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
+10 -3
View File
@@ -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());
+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}"),
},
+84 -1
View File
@@ -491,9 +491,14 @@ 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?;
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<S: HttpSend> RemoteTable<S> {
"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));