feat: return a cleanup job from drop_view (#4262)

Dropping a view unbinds its name and leaves the definition dataset to a
server-side cleanup job, so the two are separate events a caller may
want to wait on.

`drop_view_async` returns that job — the same shape
`drop_materialized_view_async` and `drop_function_async` already use: a
`202` carries the job id, a `200` (nothing was bound to the name) yields
an already-finished job with no id, and any other success status is an
error rather than a silent no-op.

## `drop_view` waits

`drop_view` now awaits the job before returning, so a caller who does
not want to think about cleanup gets the stronger guarantee: when it
returns, the definition really is deleted.

That is deliberately **different** from `drop_materialized_view` and
`drop_function`, which return as soon as the name is unbound and
document that content may still be deleting. The view API is the newer
one, and waiting is the semantic worth having; the other two are left
alone rather than changing behaviour already released.

## Surfaces

`Database` trait, the remote client, `Connection`, and the Python and
Node bindings — matching where `drop_materialized_view_async` is already
exposed.

Four client tests cover the accepted case reporting its job id, the
nothing-bound case reporting a finished job, a `202` without a usable
`job_id`, and an unexpected success status.
This commit is contained in:
Jack Ye
2026-09-24 00:27:20 -07:00
committed by GitHub
parent 85c9cf6240
commit e4d0f9e2da
12 changed files with 286 additions and 20 deletions
+29 -2
View File
@@ -820,15 +820,42 @@ impl Connection {
.await
}
/// Drop a view.
/// Drop a view and wait for its definition to be deleted.
///
/// The tables it reads are untouched: a view holds no rows of its own.
/// Local databases return [`Error::NotSupported`].
/// Use [`Connection::drop_view_async`] to get the cleanup job instead of
/// waiting on it. Local databases return [`Error::NotSupported`].
pub async fn drop_view(&self, name: impl AsRef<str>, namespace_path: &[String]) -> Result<()> {
validate_view_reference(name.as_ref(), namespace_path)?;
self.internal.drop_view(name.as_ref(), namespace_path).await
}
/// Start dropping a view and return the job deleting its definition.
///
/// The name is free before this returns; the definition dataset may still
/// be being deleted. Await [`Job::wait`][crate::job::Job::wait] to wait for
/// that. When nothing was bound to the name, the returned job is already
/// finished and has no id. Local databases return [`Error::NotSupported`].
///
/// ```no_run
/// # use lancedb::Connection;
/// # async fn drop(conn: &Connection) -> lancedb::Result<()> {
/// let job = conn.drop_view_async("recent_orders", &[]).await?;
/// job.wait().await?;
/// # Ok(())
/// # }
/// ```
pub async fn drop_view_async(
&self,
name: impl AsRef<str>,
namespace_path: &[String],
) -> Result<crate::job::Job> {
validate_view_reference(name.as_ref(), namespace_path)?;
self.internal
.drop_view_async(name.as_ref(), namespace_path)
.await
}
/// The names of the views in one namespace.
///
/// Names only; a definition is query metadata and comes from
+7 -2
View File
@@ -470,11 +470,16 @@ pub trait Database:
) -> Result<ViewDescription> {
view_ops_not_supported()
}
/// Drop a view. Its sources are untouched -- a view holds no rows of its
/// own.
/// Drop a view and wait for its definition to be deleted. Its sources are
/// untouched -- a view holds no rows of its own.
async fn drop_view(&self, _name: &str, _namespace_path: &[String]) -> Result<()> {
view_ops_not_supported()
}
/// Drop a view and return the job deleting its definition, without waiting.
#[doc(hidden)]
async fn drop_view_async(&self, _name: &str, _namespace_path: &[String]) -> Result<Job> {
view_ops_not_supported()
}
/// The names of the views in one namespace.
async fn list_views(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
view_ops_not_supported()
+79 -2
View File
@@ -1210,11 +1210,39 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
}
async fn drop_view(&self, name: &str, namespace_path: &[String]) -> Result<()> {
self.drop_view_async(name, namespace_path)
.await?
.wait()
.await
}
async fn drop_view_async(&self, name: &str, namespace_path: &[String]) -> Result<Job> {
let view_id = build_object_identifier("View name", name, namespace_path)?;
let req = self.client.post(&format!("/v1/view/{view_id}/drop"));
let (request_id, response) = self.client.send(req).await?;
self.client.check_response(&request_id, response).await?;
Ok(())
let response = self.client.check_response(&request_id, response).await?;
let status = response.status();
let body = response.text().await.err_to_http(request_id.clone())?;
match status {
// Nothing was bound to the name, so nothing is being deleted.
StatusCode::OK => Ok(Job::new_done()),
StatusCode::ACCEPTED => {
let job_id = extract_job_id(&body).ok_or_else(|| Error::Http {
source: "view drop response did not contain a valid job_id".into(),
request_id,
status_code: Some(status),
})?;
Ok(Job::new(Box::new(RemoteJob::new(
self.client.clone(),
job_id,
))))
}
_ => Err(Error::Http {
source: "view drop must return 200 OK or 202 Accepted".into(),
request_id,
status_code: Some(status),
}),
}
}
async fn list_views(&self, namespace_path: &[String]) -> Result<Vec<String>> {
@@ -4046,6 +4074,55 @@ mod tests {
.unwrap();
}
/// An accepted drop hands back the job so a caller can wait on the delete,
/// and the waiting `drop_view` does that for them.
#[tokio::test]
async fn test_drop_view_async_reports_the_cleanup_job() {
let db = super::RemoteDatabase::new_mock(|_| {
http::Response::builder()
.status(202)
.body(r#"{"job_id":"j1-do-abc"}"#)
.unwrap()
});
let job = db.drop_view_async("adults", &[]).await.unwrap();
assert_eq!(job.id(), Some("j1-do-abc"));
}
/// Nothing was bound, so nothing is being deleted and the job is already done.
#[tokio::test]
async fn test_drop_view_async_reports_a_finished_job_when_nothing_was_bound() {
let db = super::RemoteDatabase::new_mock(|_| {
http::Response::builder().status(200).body("{}").unwrap()
});
let job = db.drop_view_async("adults", &[]).await.unwrap();
assert_eq!(job.id(), None);
assert_eq!(job.status().await.unwrap(), "finished");
job.wait().await.unwrap();
}
#[tokio::test]
async fn test_drop_view_rejects_incomplete_acceptance() {
for body in ["{}", r#"{"job_id":""}"#, r#"{"job_id":null}"#] {
let db = super::RemoteDatabase::new_mock(move |_| {
http::Response::builder().status(202).body(body).unwrap()
});
let error = db.drop_view_async("adults", &[]).await.err().unwrap();
assert!(error.to_string().contains("valid job_id"), "{error}");
}
}
#[tokio::test]
async fn test_drop_view_rejects_unexpected_success_status() {
let db = super::RemoteDatabase::new_mock(|_| {
http::Response::builder().status(204).body("").unwrap()
});
let error = db.drop_view_async("adults", &[]).await.err().unwrap();
assert!(
error.to_string().contains("200 OK or 202 Accepted"),
"{error}"
);
}
/// A schema the client cannot decode is a broken response, not a view
/// with no columns: reporting it as an error keeps a caller from reading
/// an empty schema as the truth about the view.