feat: use SQL for remote view lifecycle

Stacked on #4675.

Remote view create, describe, list, and drop now execute SQL. CREATE returns the stored definition, resolution defaults, and Arrow schema from the same execution; DESCRIBE VIEW returns that metadata and SHOW VIEWS supports scoped pagination.

`drop_view_async()` returns a `job_type="query"` handle whose single `job.id` is the SQL query UUID. Waiting drains the query result once and caches completion; `describe_query(job.id)` works on the submitting connection. Completion means catalog removal, with physical cleanup continuing separately. Query handles have no registry history and cannot be reopened through `open_job()`.
This commit is contained in:
Jack Ye
2026-10-31 02:09:54 +08:00
committed by GitHub
parent 45e00d4de7
commit ac7930fa77
13 changed files with 477 additions and 562 deletions
+14 -12
View File
@@ -1178,10 +1178,11 @@ class DBConnection(EnforceOverrides):
def drop_view(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Drop a view and wait for its definition to be deleted.
"""Drop a view and wait for catalog removal.
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.
:meth:`drop_view_async` to retain its SQL query job. Completion confirms
catalog removal; physical cleanup continues in the background.
LanceDB Embedded connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
@@ -1191,12 +1192,11 @@ class DBConnection(EnforceOverrides):
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.
"""Drop a view and return its SQL query job.
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``.
The job has type ``query`` and its ID is the SQL query UUID. Call
:meth:`Job.wait` to confirm catalog removal. Physical cleanup continues
in the background. Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"View operations are not supported for this connection type"
@@ -3549,10 +3549,11 @@ class AsyncConnection(object):
async def drop_view(
self, name: str, *, namespace_path: Optional[List[str]] = None
) -> None:
"""Drop a view and wait for its definition to be deleted.
"""Drop a view and wait for catalog removal.
The tables it reads are untouched. Use :meth:`drop_view_async` to get
the cleanup job instead of waiting on it.
its SQL query job. Completion confirms catalog removal; physical cleanup
continues in the background.
"""
await self._inner.drop_view(name, list(namespace_path or []))
@@ -3562,10 +3563,11 @@ class AsyncConnection(object):
*,
namespace_path: Optional[List[str]] = None,
) -> AsyncJob[None]:
"""Start dropping a view and return the job deleting its definition.
"""Drop a view and return its SQL query job.
The name is free before this returns. Await :meth:`AsyncJob.wait` before
assuming the definition dataset is gone.
The job has type ``query`` and its ID is the SQL query UUID. Await
:meth:`AsyncJob.wait` to confirm catalog removal. Physical cleanup
continues in the background.
"""
if namespace_path is None:
namespace_path = []
+1 -1
View File
@@ -84,7 +84,7 @@ async def advance(items, asynchronous):
return await items.__anext__() if asynchronous else next(items)
KINDS = ["views", "jobs"]
KINDS = ["jobs"]
@pytest.mark.asyncio
-54
View File
@@ -4570,57 +4570,3 @@ def test_remote_job_handle_reports_its_own_detail():
"limit": 500,
"filter": "state = 'claim_complete'",
}
def test_view_crud_addresses_its_own_routes():
# The view verbs are their own routes, and the schema comes back in the
# namespace spec's JSON encoding, decoded into a pyarrow schema.
paths = []
def handler(request):
paths.append((request.command, request.path))
if request.path.endswith("/view/list"):
body = {"views": ["adults"]}
elif request.path.endswith("/drop"):
body = {}
else:
body = {
"name": "adults",
"namespace": ["analytics"],
"query": "SELECT name FROM people",
"default_database": "dev",
"default_namespace": ["analytics"],
"schema": {
"fields": [
{"name": "name", "nullable": True, "type": {"type": "utf8"}}
]
},
}
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(json.dumps(body).encode())
with mock_lancedb_connection(handler) as db:
view = db.create_view(
"adults", "SELECT name FROM people", namespace_path=["analytics"]
)
assert view.name == "adults"
assert view.namespace_path == ["analytics"]
assert view.query == "SELECT name FROM people"
assert view.default_database == "dev"
assert view.default_namespace_path == ["analytics"]
assert view.schema == pa.schema([pa.field("name", pa.utf8(), nullable=True)])
described = db.describe_view("adults", namespace_path=["analytics"])
assert described.schema == view.schema
assert list(db.list_views(namespace_path=["analytics"])) == ["adults"]
db.drop_view("adults", namespace_path=["analytics"])
assert paths == [
("POST", "/v1/view/analytics$adults/create"),
("POST", "/v1/view/analytics$adults/describe"),
("GET", "/v1/namespace/analytics/view/list"),
("POST", "/v1/view/analytics$adults/drop"),
]