feat: return a cleanup job from drop_view

Dropping a view unbinds its name and leaves the definition dataset to a cleanup
job, so the two are separate events a caller may want to wait on.
`drop_view_async` returns the job, the same shape `drop_materialized_view_async`
and `drop_function_async` already use: a 202 carries the job id, and a 200 --
nothing bound to the name -- yields an already-finished job.

`drop_view` waits on that job before returning, so a caller that does not want
to think about cleanup gets the stronger guarantee: when it returns, the
definition really is deleted. That differs from `drop_materialized_view` and
`drop_function`, which return as soon as the name is unbound; the view API is
the newer one and this is the semantic worth having.
This commit is contained in:
Jack Ye
2026-09-23 21:32:27 -07:00
parent 85c9cf6240
commit 75eefedc4b
8 changed files with 207 additions and 10 deletions
+18 -1
View File
@@ -427,7 +427,8 @@ impl Connection {
ViewDescription::from_inner(view)
}
/// Drop a view. The tables it reads are untouched.
/// Drop a view and wait for its definition to be deleted. The tables it
/// reads are untouched.
#[napi(catch_unwind)]
pub async fn drop_view(
&self,
@@ -441,6 +442,22 @@ impl Connection {
.default_error()
}
/// Start dropping a view and return the job deleting its definition.
#[napi(catch_unwind)]
pub async fn drop_view_async(
&self,
name: String,
namespace_path: Option<Vec<String>>,
) -> napi::Result<crate::job::Job> {
let ns = namespace_path.unwrap_or_default();
let job = self
.get_inner()?
.drop_view_async(&name, &ns)
.await
.default_error()?;
Ok(crate::job::Job::new(job))
}
/// The names of the views in one namespace.
#[napi(catch_unwind)]
pub async fn list_views(
+3
View File
@@ -180,6 +180,9 @@ class Connection(object):
async def drop_view(
self, name: str, namespace_path: Optional[List[str]] = None
) -> None: ...
async def drop_view_async(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Job: ...
async def list_views(
self, namespace_path: Optional[List[str]] = None
) -> List[str]: ...
+47 -3
View File
@@ -983,15 +983,30 @@ class DBConnection(EnforceOverrides):
def drop_view(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""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.
The tables it reads are untouched: a view holds no rows of its own. Use
:meth:`drop_view_async` to get the cleanup job instead of waiting on it.
Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"View operations are not supported for this connection type"
)
def drop_view_async(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> "Job[None]":
"""Start dropping a view and return the job deleting its definition.
The name is free before this returns. Call :meth:`Job.wait` to wait for
the definition dataset to be deleted. When nothing was bound to the
name, the returned job is already finished and has no id. Local
connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"View operations are not supported for this connection type"
)
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
"""The names of the views in one namespace.
@@ -1836,6 +1851,14 @@ class LanceDBConnection(DBConnection):
) -> None:
LOOP.run(self._conn.drop_view(name, namespace_path=namespace_path))
@override
def drop_view_async(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> "Job[None]":
return Job(
LOOP.run(self._conn.drop_view_async(name, namespace_path=namespace_path))
)
@override
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
@@ -2821,9 +2844,30 @@ class AsyncConnection(object):
async def drop_view(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Drop a view. The tables it reads are untouched."""
"""Drop a view and wait for its definition to be deleted.
The tables it reads are untouched. Use :meth:`drop_view_async` to get
the cleanup job instead of waiting on it.
"""
await self._inner.drop_view(name, list(namespace_path or []))
async def drop_view_async(
self,
name: str,
*,
namespace_path: Optional[List[str]] = None,
) -> AsyncJob[None]:
"""Start dropping a view and return the job deleting its definition.
The name is free before this returns. Await :meth:`AsyncJob.wait` before
assuming the definition dataset is gone.
"""
if namespace_path is None:
namespace_path = []
return AsyncJob(
await self._inner.drop_view_async(name, namespace_path=namespace_path)
)
async def list_views(
self, *, namespace_path: Optional[List[str]] = None
) -> List[str]:
+7
View File
@@ -931,6 +931,13 @@ class RemoteDBConnection(DBConnection):
) -> None:
LOOP.run(self._conn.drop_view(name, namespace_path=namespace_path))
@override
def drop_view_async(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> Job[None]:
job = LOOP.run(self._conn.drop_view_async(name, namespace_path=namespace_path))
return Job(job)
@override
def list_views(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
return LOOP.run(self._conn.list_views(namespace_path=namespace_path))
+17
View File
@@ -936,6 +936,23 @@ impl Connection {
})
}
#[pyo3(signature = (name, namespace_path=None))]
pub fn drop_view_async(
self_: PyRef<'_, Self>,
name: String,
namespace_path: Option<Vec<String>>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
let namespace_path = namespace_path.unwrap_or_default();
future_into_py(self_.py(), async move {
inner
.drop_view_async(name, &namespace_path)
.await
.infer_error()
.map(crate::job::Job::new)
})
}
#[pyo3(signature = (namespace_path=None))]
pub fn list_views(
self_: PyRef<'_, Self>,
+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.