mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-12 14:52:26 +00:00
fix(python): close synchronous database connections (#4424)
Calling `close()` on a synchronous remote connection returned an unawaited coroutine and left the connection usable. Local synchronous connections had no close or context-manager API. Add synchronous `close()` and `is_open()` to the connection interface, delegate them to the existing connection handle for local, remote, and namespace connections, and provide shared context-manager cleanup. Closing is idempotent, and subsequent database operations use the existing `Connection is closed` error. Document the lifecycle with examples. Validation: - Reproduced the reported coroutine warning and missing local lifecycle API on the original code; all 15 new regression cases failed before the fix. - All 168 tests in `test_db.py`, `test_remote_db.py`, and `test_namespace.py` passed through the uv-managed environment, including local, in-memory, namespace, native-derived, and mocked remote lifecycle coverage. - All 6 synchronous connection doctests passed. - Repository-wide Ruff 0.9.9 lint and formatting passed; `git diff --check` passed. Fixes #4382 <!-- lance-gatekeeper-fix:v1 agent=a743139af98198f1d4c8934c9cc1d0c9 generation=1 --> Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
@@ -122,7 +122,48 @@ def _view_description(
|
||||
|
||||
|
||||
class DBConnection(EnforceOverrides):
|
||||
"""An active LanceDB connection interface."""
|
||||
"""An active LanceDB connection interface.
|
||||
|
||||
Use [close][lancedb.db.DBConnection.close] to release the connection's
|
||||
underlying resources, or use the connection as a context manager to close it
|
||||
automatically when leaving the block, including when an exception is raised.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> with lancedb.connect("memory://") as db:
|
||||
... assert db.is_open()
|
||||
>>> db.is_open()
|
||||
False
|
||||
"""
|
||||
|
||||
def __enter__(self) -> DBConnection:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_) -> None:
|
||||
self.close()
|
||||
|
||||
@abstractmethod
|
||||
def is_open(self) -> bool:
|
||||
"""Return True if the connection is open."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Close the connection, releasing any underlying resources.
|
||||
|
||||
It is safe to call this method multiple times. Database operations on a
|
||||
closed connection raise ``RuntimeError: Connection is closed``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import lancedb
|
||||
>>> db = lancedb.connect("memory://")
|
||||
>>> db.close()
|
||||
>>> db.is_open()
|
||||
False
|
||||
"""
|
||||
pass
|
||||
|
||||
def list_namespaces(
|
||||
self,
|
||||
@@ -1275,6 +1316,14 @@ class LanceDBConnection(DBConnection):
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(uri={self._conn.uri!r})"
|
||||
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._conn.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
@override
|
||||
def serialize(self) -> str:
|
||||
import json
|
||||
|
||||
@@ -489,6 +489,14 @@ class LanceNamespaceDBConnection(DBConnection):
|
||||
)
|
||||
self._uri = self._inner.uri
|
||||
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._inner.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._inner.close()
|
||||
|
||||
@override
|
||||
def serialize(self) -> str:
|
||||
import json
|
||||
|
||||
@@ -1054,6 +1054,10 @@ class RemoteDBConnection(DBConnection):
|
||||
"""
|
||||
return LOOP.run(self._conn.namespace_client())
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection to the database."""
|
||||
@override
|
||||
def is_open(self) -> bool:
|
||||
return self._conn.is_open()
|
||||
|
||||
@override
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
@@ -405,6 +406,49 @@ async def test_connect(tmp_path):
|
||||
assert str(db) == f"ListingDatabase(uri={tmp_path}, read_consistency_interval=5s)"
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "local", "namespace", "from_inner"])
|
||||
def sync_db(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
return lancedb.connect("memory://")
|
||||
if request.param == "namespace":
|
||||
return lancedb.connect_namespace("dir", {"root": str(tmp_path)})
|
||||
db = lancedb.connect(tmp_path)
|
||||
if request.param == "from_inner":
|
||||
return lancedb.db.LanceDBConnection.from_inner(db._inner, None)
|
||||
return db
|
||||
|
||||
|
||||
def test_sync_close(sync_db):
|
||||
assert sync_db.is_open()
|
||||
assert sync_db.close() is None
|
||||
assert not sync_db.is_open()
|
||||
assert sync_db.close() is None
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="table_names"):
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.table_names()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.list_tables()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.open_table("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raise_error", [False, True])
|
||||
def test_sync_context_manager(sync_db, raise_error):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if raise_error:
|
||||
stack.enter_context(pytest.raises(ValueError, match="test error"))
|
||||
with sync_db as db:
|
||||
assert db is sync_db
|
||||
assert db.is_open()
|
||||
if raise_error:
|
||||
raise ValueError("test error")
|
||||
|
||||
assert not sync_db.is_open()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
sync_db.list_tables()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close(mem_db_async: lancedb.AsyncConnection):
|
||||
assert mem_db_async.is_open()
|
||||
|
||||
@@ -2145,15 +2145,46 @@ async def test_header_provider_overrides_static_headers():
|
||||
|
||||
|
||||
def test_close():
|
||||
"""Test that close() works without AttributeError."""
|
||||
import asyncio
|
||||
|
||||
def handler(req):
|
||||
req.send_response(200)
|
||||
req.end_headers()
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
asyncio.run(db.close())
|
||||
assert db.close() is None
|
||||
assert not db.is_open()
|
||||
assert db.close() is None
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="table_names"):
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.table_names()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.list_tables()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.open_table("test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raise_error", [False, True])
|
||||
def test_sync_context_manager(raise_error):
|
||||
def handler(req):
|
||||
req.send_response(200)
|
||||
req.send_header("Content-Type", "application/json")
|
||||
req.end_headers()
|
||||
req.wfile.write(b'{"tables": []}')
|
||||
|
||||
with mock_lancedb_connection(handler) as db:
|
||||
with contextlib.ExitStack() as stack:
|
||||
if raise_error:
|
||||
stack.enter_context(pytest.raises(ValueError, match="test error"))
|
||||
with db as conn:
|
||||
assert conn is db
|
||||
assert conn.is_open()
|
||||
assert conn.list_tables().tables == []
|
||||
if raise_error:
|
||||
raise ValueError("test error")
|
||||
|
||||
assert not db.is_open()
|
||||
with pytest.raises(RuntimeError, match="Connection is closed"):
|
||||
db.list_tables()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception", [KeyboardInterrupt, SystemExit, GeneratorExit])
|
||||
|
||||
Reference in New Issue
Block a user