From 4dc2d9a0f29602ad15094cbd5da97de4ede90bca Mon Sep 17 00:00:00 2001 From: heart Date: Thu, 30 Jul 2026 22:56:13 +0800 Subject: [PATCH] fix(python): avoid async work in sync reprs (#3620) ## Summary - keep the existing synchronous `connect()` path unchanged - make `LanceDBConnection.__repr__` and `LanceTable.__repr__` side-effect-free - add a regression test that verifies sync reprs do not call the Python background loop ## Root cause The freeze is caused by debugger rendering, not by `connect()` itself: 1. debugpy stops at a breakpoint and suspends all Python threads. 2. The debugger renders the new `db_connection` local by calling `repr()`. 3. `LanceDBConnection.__repr__` reads `read_consistency_interval`. 4. That property calls `LOOP.run(...).result()`. 5. The `LanceDBBackgroundEventLoop` thread is suspended by the debugger, so `repr()` waits for a thread that cannot run. This explains why the symptom appears immediately after `connect()`: it is the first point where a connection object exists in locals and is automatically rendered. `LanceTable.__repr__` had the same problem because it also read the connection's consistency interval. This follows the same principle as #3411: `__repr__` must not trigger async work or I/O that a debugger assumes is lightweight. ## Evidence I reproduced the behavior with the real LanceDB classes and debugpy 1.8.21 using a DAP client: - latest `main` (`ff6ff099`): the debugger reported `allThreadsStopped: true`, and evaluating `repr(db_connection)` timed out - this branch (`5755a5ba`): the same evaluation returned `LanceDBConnection(uri='/tmp/lancedb-debug-repro')` immediately - setting `PYDEVD_UNBLOCK_THREADS_TIMEOUT=0` also allowed the original repr path to complete, independently confirming that it was waiting on a suspended thread The regression test creates a connection and table, replaces `LOOP.run` with a function that fails, and verifies that both reprs still work. ## Validation - `maturin develop --manifest-path python/Cargo.toml` - `python -m pytest python/python/tests/test_db.py::test_sync_repr_does_not_use_background_loop python/python/tests/test_table.py::test_consistency -q` (`4 passed`) - `ruff check .` - `ruff format --check python/python/lancedb/db.py python/python/lancedb/table.py python/python/tests/test_db.py python/python/tests/test_table.py` - `git diff --check` Refs #3611. --- python/python/lancedb/db.py | 6 +----- python/python/lancedb/table.py | 8 +------- python/python/tests/test_db.py | 15 +++++++++++++++ python/python/tests/test_table.py | 3 --- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 0db9003d1..fd6240d48 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -688,11 +688,7 @@ class LanceDBConnection(DBConnection): return cls(None, _inner=inner) def __repr__(self) -> str: - val = f"{self.__class__.__name__}(uri={self._conn.uri!r}" - if self.read_consistency_interval is not None: - val += f", read_consistency_interval={repr(self.read_consistency_interval)}" - val += ")" - return val + return f"{self.__class__.__name__}(uri={self._conn.uri!r})" @override def serialize(self) -> str: diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index 032c6dce7..56764bfd3 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -2468,13 +2468,7 @@ class LanceTable(Table): return LOOP.run(self._table.count_rows(filter)) def __repr__(self) -> str: - val = f"{self.__class__.__name__}(name={self.name!r}" - if self._conn.read_consistency_interval is not None: - val += ", read_consistency_interval={!r}".format( - self._conn.read_consistency_interval - ) - val += f", _conn={self._conn!r})" - return val + return f"{self.__class__.__name__}(name={self.name!r}, _conn={self._conn!r})" def __str__(self) -> str: return self.__repr__() diff --git a/python/python/tests/test_db.py b/python/python/tests/test_db.py index 2bc4a0611..86b70b75d 100644 --- a/python/python/tests/test_db.py +++ b/python/python/tests/test_db.py @@ -62,6 +62,21 @@ def test_basic(tmp_path): assert db.open_table("test").name == db["test"].name +def test_sync_repr_does_not_use_background_loop(tmp_path, monkeypatch): + from lancedb.background_loop import LOOP + + db = lancedb.connect(tmp_path) + table = db.create_table("test", data=[{"id": 1}]) + + def fail_run(*args, **kwargs): + raise AssertionError("repr should not use the Python background loop") + + monkeypatch.setattr(LOOP, "run", fail_run) + + assert repr(db) == f"LanceDBConnection(uri={str(tmp_path)!r})" + assert repr(table) == f"LanceTable(name='test', _conn={db!r})" + + def test_ingest_pd(tmp_path): db = lancedb.connect(tmp_path) diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index b281e74da..2a34084cf 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -3087,9 +3087,6 @@ def test_consistency(tmp_path, consistency_interval): db2 = lancedb.connect(tmp_path, read_consistency_interval=consistency_interval) table2 = db2.open_table("my_table") - if consistency_interval is not None: - assert "read_consistency_interval=datetime.timedelta(" in repr(db2) - assert "read_consistency_interval=datetime.timedelta(" in repr(table2) assert table2.version == table.version table.add([{"id": 1}])