Skip to content

Commit 85c1cb6

Browse files
cpcloudncclementi
authored andcommitted
fix(oracle): avoid double cursor closing by removing unnecessary close in _fetch_from_cursor (#9913)
Previously we were trying to close a cursor after an exception was raised in `raw_sql`, which already closes the cursor in the case of an exception. This is not allowed by the oracledb driver, so just close the cursor on success.
1 parent 8b0fb66 commit 85c1cb6

File tree

6 files changed

+58
-42
lines changed

6 files changed

+58
-42
lines changed

ibis/backends/__init__.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import keyword
88
import re
99
import urllib.parse
10+
import weakref
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING, Any, ClassVar
1213

@@ -34,6 +35,12 @@
3435
class TablesAccessor(collections.abc.Mapping):
3536
"""A mapping-like object for accessing tables off a backend.
3637
38+
::: {.callout-note}
39+
## The `tables` accessor is tied to the lifetime of the backend.
40+
41+
If the backend goes out of scope, the `tables` accessor is no longer valid.
42+
:::
43+
3744
Tables may be accessed by name using either index or attribute access:
3845
3946
Examples
@@ -804,12 +811,7 @@ def __init__(self, *args, **kwargs):
804811
self._con_args: tuple[Any] = args
805812
self._con_kwargs: dict[str, Any] = kwargs
806813
self._can_reconnect: bool = True
807-
# expression cache
808-
self._query_cache = RefCountedCache(
809-
populate=self._load_into_cache,
810-
lookup=lambda name: self.table(name).op(),
811-
finalize=self._clean_up_cached_table,
812-
)
814+
self._query_cache = RefCountedCache(weakref.proxy(self))
813815

814816
@property
815817
@abc.abstractmethod
@@ -1017,7 +1019,7 @@ def tables(self):
10171019
>>> people = con.tables.people # access via attribute
10181020
10191021
"""
1020-
return TablesAccessor(self)
1022+
return TablesAccessor(weakref.proxy(self))
10211023

10221024
@property
10231025
@abc.abstractmethod

ibis/backends/duckdb/tests/test_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import gc
34
import os
45
import subprocess
56
import sys
@@ -403,3 +404,23 @@ def test_read_csv_with_types(tmp_path, input, all_varchar):
403404
path.write_bytes(data)
404405
t = con.read_csv(path, all_varchar=all_varchar, **input)
405406
assert t.schema()["geom"].is_geospatial()
407+
408+
409+
def test_tables_accessor_no_reference_cycle():
410+
"""Test that a single reference to a connection has the desired lifetime semantics."""
411+
con = ibis.duckdb.connect()
412+
413+
before = len(gc.get_referrers(con))
414+
tables = con.tables
415+
after = len(gc.get_referrers(con))
416+
417+
assert after == before
418+
419+
# valid call, and there are no tables in the database
420+
assert not list(tables)
421+
422+
del con
423+
424+
# no longer valid because the backend has been manually decref'd
425+
with pytest.raises(ReferenceError):
426+
list(tables)

ibis/backends/oracle/__init__.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -623,19 +623,8 @@ def _fetch_from_cursor(self, cursor, schema: sch.Schema) -> pd.DataFrame:
623623

624624
from ibis.backends.oracle.converter import OraclePandasData
625625

626-
try:
627-
df = pd.DataFrame.from_records(
628-
cursor, columns=schema.names, coerce_float=True
629-
)
630-
except Exception:
631-
# clean up the cursor if we fail to create the DataFrame
632-
#
633-
# in the sqlite case failing to close the cursor results in
634-
# artificially locked tables
635-
cursor.close()
636-
raise
637-
df = OraclePandasData.convert_table(df, schema)
638-
return df
626+
df = pd.DataFrame.from_records(cursor, columns=schema.names, coerce_float=True)
627+
return OraclePandasData.convert_table(df, schema)
639628

640629
def _clean_up_tmp_table(self, name: str) -> None:
641630
with self.begin() as bind:

ibis/backends/tests/test_api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import gc
4+
35
import pytest
46
from pytest import param
57

@@ -115,6 +117,16 @@ def test_tables_accessor_repr(con):
115117
assert f"- {name}" in result
116118

117119

120+
def test_tables_accessor_no_reference_cycle(con):
121+
before = len(gc.get_referrers(con))
122+
_ = con.tables
123+
after = len(gc.get_referrers(con))
124+
125+
# assert that creating a `tables` accessor object doesn't increase the
126+
# number of strong references
127+
assert after == before
128+
129+
118130
@pytest.mark.parametrize(
119131
"expr_fn",
120132
[

ibis/backends/tests/test_temporal.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
MySQLOperationalError,
3030
MySQLProgrammingError,
3131
OracleDatabaseError,
32-
OracleInterfaceError,
3332
PolarsInvalidOperationError,
3433
PolarsPanicException,
3534
PsycoPg2InternalError,
@@ -505,8 +504,8 @@ def test_date_truncate(backend, alltypes, df, unit):
505504
),
506505
pytest.mark.notyet(
507506
["oracle"],
508-
raises=OracleInterfaceError,
509-
reason="cursor not open, probably a bug in the sql generated",
507+
raises=OracleDatabaseError,
508+
reason="ORA-01839: date not valid for month specified",
510509
),
511510
sqlite_without_ymd_intervals,
512511
],
@@ -633,8 +632,8 @@ def convert_to_offset(offset, displacement_type=displacement_type):
633632
),
634633
pytest.mark.notyet(
635634
["oracle"],
636-
raises=OracleInterfaceError,
637-
reason="cursor not open, probably a bug in the sql generated",
635+
raises=OracleDatabaseError,
636+
reason="ORA-01839: date not valid for month specified",
638637
),
639638
sqlite_without_ymd_intervals,
640639
],

ibis/common/caching.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import functools
44
import sys
5+
import weakref
56
from collections import namedtuple
67
from typing import TYPE_CHECKING, Any
7-
from weakref import finalize, ref
88

99
if TYPE_CHECKING:
1010
from collections.abc import Callable
@@ -39,17 +39,8 @@ class RefCountedCache:
3939
We can implement that interface if and when we need to.
4040
"""
4141

42-
def __init__(
43-
self,
44-
*,
45-
populate: Callable[[str, Any], None],
46-
lookup: Callable[[str], Any],
47-
finalize: Callable[[Any], None],
48-
) -> None:
49-
self.populate = populate
50-
self.lookup = lookup
51-
self.finalize = finalize
52-
42+
def __init__(self, backend: weakref.proxy) -> None:
43+
self.backend = backend
5344
self.cache: dict[Any, CacheEntry] = dict()
5445

5546
def get(self, key, default=None):
@@ -70,11 +61,13 @@ def store(self, input):
7061

7162
key = input.op()
7263
name = gen_name("cache")
73-
self.populate(name, input)
74-
cached = self.lookup(name)
75-
finalizer = finalize(cached, self._release, key)
7664

77-
self.cache[key] = CacheEntry(name, ref(cached), finalizer)
65+
self.backend._load_into_cache(name, input)
66+
67+
cached = self.backend.table(name).op()
68+
finalizer = weakref.finalize(cached, self._release, key)
69+
70+
self.cache[key] = CacheEntry(name, weakref.ref(cached), finalizer)
7871

7972
return cached
8073

@@ -88,7 +81,7 @@ def release(self, name: str) -> None:
8881
def _release(self, key) -> None:
8982
entry = self.cache.pop(key)
9083
try:
91-
self.finalize(entry.name)
84+
self.backend._clean_up_cached_table(entry.name)
9285
except Exception:
9386
# suppress exceptions during interpreter shutdown
9487
if not sys.is_finalizing():

0 commit comments

Comments
 (0)