This is an automated email from the ASF dual-hosted git repository.

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 8286d178f fix(python/adbc_driver_manager): close Cursors when closing 
Connection (#3810)
8286d178f is described below

commit 8286d178f1863e7731910a0da2b84b07ef343489
Author: Philip Moore <[email protected]>
AuthorDate: Wed Dec 17 18:13:48 2025 -0500

    fix(python/adbc_driver_manager): close Cursors when closing Connection 
(#3810)
    
    Closes #3713.
    
    ---------
    
    Co-authored-by: Bryce Mecum <[email protected]>
---
 .../adbc_driver_manager/dbapi.py                   |  20 ++-
 python/adbc_driver_manager/tests/test_dbapi.py     | 138 +++++++++++++++++++++
 2 files changed, 157 insertions(+), 1 deletion(-)

diff --git a/python/adbc_driver_manager/adbc_driver_manager/dbapi.py 
b/python/adbc_driver_manager/adbc_driver_manager/dbapi.py
index 91072768e..fb83b04f3 100644
--- a/python/adbc_driver_manager/adbc_driver_manager/dbapi.py
+++ b/python/adbc_driver_manager/adbc_driver_manager/dbapi.py
@@ -46,6 +46,7 @@ import threading
 import time
 import typing
 import warnings
+import weakref
 from typing import Any, Dict, List, Literal, Optional, Tuple, Union
 
 try:
@@ -341,6 +342,7 @@ class Connection(_Closeable):
             self._db = _SharedDatabase(db)
         self._conn = conn
         self._conn_kwargs = conn_kwargs
+        self._cursors: "weakref.WeakSet[Cursor]" = weakref.WeakSet()
 
         if autocommit:
             self._autocommit = True
@@ -364,6 +366,10 @@ class Connection(_Closeable):
         """
         Close the connection.
 
+        Notes
+        -----
+        This will also close any open cursors associated with this connection.
+
         Warnings
         --------
         Failure to close a connection may leak memory or database
@@ -372,6 +378,16 @@ class Connection(_Closeable):
         if self._closed:
             return
 
+        # Try to close all open cursors first to avoid RuntimeError from
+        # AdbcConnection about open children
+        for cursor in self._cursors:
+            try:
+                cursor.close()
+            except Exception as e:
+                warnings.warn(
+                    f"Failed to close cursor: {e}", ResourceWarning, 
stacklevel=2
+                )
+
         self._conn.close()
         self._db.close()
         self._closed = True
@@ -394,7 +410,9 @@ class Connection(_Closeable):
         adbc_stmt_kwargs : dict, optional
           ADBC-specific options to pass to the underlying ADBC statement.
         """
-        return Cursor(self, adbc_stmt_kwargs, dbapi_backend=self._backend)
+        cursor = Cursor(self, adbc_stmt_kwargs, dbapi_backend=self._backend)
+        self._cursors.add(cursor)
+        return cursor
 
     def rollback(self) -> None:
         """Explicitly rollback."""
diff --git a/python/adbc_driver_manager/tests/test_dbapi.py 
b/python/adbc_driver_manager/tests/test_dbapi.py
index 38c836a58..ed58a2905 100644
--- a/python/adbc_driver_manager/tests/test_dbapi.py
+++ b/python/adbc_driver_manager/tests/test_dbapi.py
@@ -569,6 +569,144 @@ def test_dbapi_extensions(sqlite):
         assert cur.execute("SELECT 42").fetchall() == [(42,)]
 
 
[email protected]
+def test_close_connection_with_open_cursor():
+    """
+    Regression test for https://github.com/apache/arrow-adbc/issues/3713
+
+    Closing a connection should automatically close any open cursors
+    without raising RuntimeError about open AdbcStatement.
+
+    This test intentionally avoids context managers because the bug only
+    manifests when users manually call close() without first closing cursors.
+    """
+    conn = dbapi.connect(driver="adbc_driver_sqlite")
+    cursor = conn.cursor()
+    cursor.execute("SELECT 1")
+    # Fetch the result to ensure the cursor is "active"
+    cursor.fetch_arrow_table()
+
+    # This should NOT raise:
+    # RuntimeError: Cannot close AdbcConnection with open AdbcStatement
+    conn.close()
+
+    # Verify cursor is also closed
+    assert cursor._closed
+
+
[email protected]
+def test_close_connection_with_multiple_open_cursors():
+    """
+    Test that closing a connection closes all open cursors.
+
+    This test intentionally avoids context managers because the bug only
+    manifests when users manually call close() without first closing cursors.
+    """
+    conn = dbapi.connect(driver="adbc_driver_sqlite")
+    cursor1 = conn.cursor()
+    cursor2 = conn.cursor()
+    cursor3 = conn.cursor()
+
+    cursor1.execute("SELECT 1")
+    cursor2.execute("SELECT 2")
+    # cursor3 is not executed
+
+    # This should close all cursors
+    conn.close()
+
+    assert cursor1._closed
+    assert cursor2._closed
+    assert cursor3._closed
+
+
[email protected]
+def test_close_connection_cursor_already_closed():
+    """
+    Test that closing a connection works even if some cursors are already 
closed.
+
+    This test intentionally avoids context managers because the bug only
+    manifests when users manually call close() without first closing cursors.
+    """
+    conn = dbapi.connect(driver="adbc_driver_sqlite")
+    cursor1 = conn.cursor()
+    cursor2 = conn.cursor()
+
+    cursor1.execute("SELECT 1")
+    cursor1.close()  # Manually close cursor1
+
+    cursor2.execute("SELECT 2")
+    # cursor2 is still open
+
+    # This should work without issues
+    conn.close()
+
+    assert cursor1._closed
+    assert cursor2._closed
+
+
[email protected]
+def test_close_connection_via_context_manager_with_open_cursors():
+    """
+    Test that exiting a connection context manager closes open cursors.
+
+    This test uses a context manager for the connection but intentionally
+    avoids context managers for cursors to verify that Connection.__exit__
+    properly closes any open cursors.
+    """
+    with dbapi.connect(driver="adbc_driver_sqlite") as conn:
+        cursor1 = conn.cursor()
+        cursor2 = conn.cursor()
+
+        cursor1.execute("SELECT 1")
+        cursor2.execute("SELECT 2")
+
+        # Cursors are open at this point
+        assert not cursor1._closed
+        assert not cursor2._closed
+
+    # After exiting the context manager, cursors should be closed
+    assert cursor1._closed
+    assert cursor2._closed
+
+
[email protected]
+def test_close_connection_suppresses_cursor_close_error():
+    """
+    Test that closing a connection suppresses exceptions from cursor.close().
+
+    When a cursor's close() method raises an exception, the connection should
+    still close successfully without propagating the error, but should emit
+    a ResourceWarning.
+    """
+    from unittest.mock import MagicMock
+
+    conn = dbapi.connect(driver="adbc_driver_sqlite")
+    # Create a real cursor so we have something legitimate in _cursors
+    real_cursor = conn.cursor()
+    real_cursor.execute("SELECT 1")
+
+    # Create a mock cursor that raises on close
+    mock_cursor = MagicMock()
+    mock_cursor.close.side_effect = RuntimeError("Simulated cursor close 
failure")
+
+    # Add the mock cursor to the connection's cursor set
+    conn._cursors.add(mock_cursor)
+
+    # This should NOT raise despite the mock cursor raising on close,
+    # but should emit a ResourceWarning
+    with pytest.warns(ResourceWarning, match="Failed to close cursor"):
+        conn.close()
+
+    # Verify the connection is closed
+    assert conn._closed
+
+    # Verify the mock cursor's close was called
+    mock_cursor.close.assert_called_once()
+
+    # Verify the real cursor is also closed
+    assert real_cursor._closed
+
+
 @pytest.mark.sqlite
 def test_connect(tmp_path: pathlib.Path, monkeypatch) -> None:
     with dbapi.connect(driver="adbc_driver_sqlite") as conn:

Reply via email to