From 9a1ffb9e02bceddd6c3e57e8501cdaf788e5ad15 Mon Sep 17 00:00:00 2001 From: Bruno Ramirez Date: Tue, 1 Sep 2026 12:54:53 -0600 Subject: [PATCH] fix(remote): forward create index replace flag (#4115) Remote create-index requests already expose `replace` on the builder, but the remote client did not consistently forward an explicit `replace=false` over REST. That meant create-only intent could be lost before it reached a remote server, even though local builders and Python APIs can express it. This PR forwards `replace=false` on the existing `create_index` endpoint and keeps the current default behavior unchanged for compatibility. This was accomplished with the following changes: - Serialize `replace: false` into the existing remote create-index request body when the builder is configured with `.replace(false)`. - Forward `replace` through the synchronous Python remote `create_index` wrapper so `RemoteTable.create_index(..., replace=False)` reaches the repaired path. - Continue omitting `replace` for the default path so existing remote create-index requests keep their current semantics. - Document `name` and `replace` on the existing OpenAPI create-index request schema. - Add coverage that verifies the remote client uses the existing `/create_index/` route and forwards `replace=false`, including the synchronous Python unified API. ### Testing - `cargo fmt --all --check` - `cargo test -p lancedb --features remote test_create_index_forwards_replace_false_on_existing_route --locked` - `uv tool run maturin develop --extras tests,dev,embeddings` - `uv run --frozen pytest python/tests/test_remote_db.py::test_remote_create_index_new_api` - `uv run ruff format --check python/lancedb/remote/table.py python/tests/test_remote_db.py` - `cargo build -p lancedb --features remote --locked` - `cargo clippy -p lancedb --features remote --all-targets --locked -- -D warnings` --- docs/openapi.yml | 9 +++++++ python/python/lancedb/remote/table.py | 1 + python/python/tests/test_remote_db.py | 10 ++++++- rust/lancedb/src/remote/table.rs | 38 +++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 2f9ae7d99..c4cb19754 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 diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index 55014a423..3eb9cbfa1 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -548,6 +548,7 @@ class RemoteTable(Table): LOOP.run( self._table.create_index( column, + replace=replace, config=config, wait_timeout=wait_timeout, name=name, diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index 01e2cc4c5..add995de1 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -820,11 +820,13 @@ def test_table_create_indices(): scalar_req = received_requests[0] assert "name" in scalar_req assert scalar_req["name"] == "custom_scalar_idx" + assert scalar_req["replace"] is False # Check FTS index request has custom name fts_req = received_requests[1] assert "name" in fts_req assert fts_req["name"] == "custom_fts_idx" + assert fts_req["replace"] is False assert fts_req["block_size"] == 256 assert fts_req["custom_stop_words"] == ["cloud"] @@ -832,6 +834,7 @@ def test_table_create_indices(): vector_req = received_requests[2] assert "name" in vector_req assert vector_req["name"] == "custom_vector_idx" + assert "replace" not in vector_req table.wait_for_index(["custom_scalar_idx"], timedelta(seconds=2)) table.wait_for_index( @@ -1104,6 +1107,9 @@ def test_remote_create_index_new_api(): table.create_index("text", config=FTS(block_size=256)) # IvfRq via new API table.create_index("vector", config=IvfRq(distance_type="l2")) + table.create_index( + "vector", config=IvfPq(distance_type="l2"), replace=False + ) # Legacy index_type="IVF_RQ" routes to IvfRq config under the hood. with pytest.warns(DeprecationWarning, match="create_index"): @@ -1113,15 +1119,17 @@ def test_remote_create_index_new_api(): num_partitions=8, ) - assert len(received_requests) == 5 + assert len(received_requests) == 6 assert [req["column"] for req in received_requests] == [ "vector", "category", "text", "vector", "vector", + "vector", ] assert received_requests[2]["block_size"] == 256 + assert received_requests[4]["replace"] is False def test_table_wait_for_index_timeout(): diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index daea028e9..ae5338d65 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -527,6 +527,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()); @@ -6233,6 +6237,40 @@ mod tests { } } + #[tokio::test] + async fn test_create_index_forwards_replace_false_on_existing_route() { + 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/" => { + 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_returns_job() { let describe_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));