Skip to content

Commit 509eba2

Browse files
committed
Store: close store when dropped, keep log callback alive
Locating the model JSON file used inspect.stack(), which created a reference cycle via frames. This kept a no longer referenced Store alive until the cyclic GC ran, so the store stayed open. On Windows this made tests fail ("another store is still open using the same path"), as the DB files of an open store cannot be deleted there (Linux allows it). - Walk the frames manually and release the frame reference - Store keeps a reference to the C log callback object while it may be called - Fix logging.info() call missing a format placeholder - Tests: close stores explicitly; add test_store_closed_when_dropped
1 parent 52040b4 commit 509eba2

5 files changed

Lines changed: 50 additions & 12 deletions

File tree

‎objectbox/store.py‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def __init__(self,
127127
"""
128128

129129
self._c_store = None
130+
self._c_log_callback = None
130131
if not c_store:
131132
options = StoreOptions()
132133
try:
@@ -185,6 +186,8 @@ def __init__(self,
185186
except DbError:
186187
options._free()
187188
raise
189+
# Keep the C callback object alive as long as the store, which may call it (options are freed by open)
190+
self._c_log_callback = getattr(options, "_c_log_cb", None)
188191
self._c_store = c.obx_store_open(options._c_handle)
189192
else:
190193
self._c_store = c_store
@@ -220,24 +223,33 @@ def get_module_path(module: Optional[ModuleType]) -> Optional[str]:
220223
def json_file_inside_module_path(module: Optional[ModuleType]) -> Optional[str]:
221224
module_path = get_module_path(module)
222225
if module_path:
223-
logging.info("Using module path to locate objectbox-model.json: ", module_path)
226+
logging.info("Using module path to locate objectbox-model.json: %s", module_path)
224227
return os.path.join(module_path, "objectbox-model.json")
225228
return None
226229

227-
# The (direct) calling module seems like a good first choice
230+
# The (direct) calling module seems like a good first choice, i.e. the user module that creates the Store.
231+
# To find it, go up the call stack, one frame (function call) at a time via f_back (the caller's frame),
232+
# and pick the first frame whose module (identified via the frame's globals) is outside the objectbox package.
233+
# Note: do not use inspect.stack() and delete the frame reference when done (finally block).
234+
# Holding on to frames creates a reference cycle, which would keep the Store being constructed (a local
235+
# of a caller's frame) alive until the cyclic GC runs; i.e. the store would not be closed when dropped.
228236
this_module = sys.modules[__name__]
229237
this_module_path = get_module_path(this_module)
230-
stack = inspect.stack()
231238
calling_module: Optional[ModuleType] = None
232-
for stack_element in stack:
233-
module = inspect.getmodule(stack_element[0])
234-
if module is not this_module:
235-
path = get_module_path(module)
236-
if not path: # Cannot get the direct caller's path, so do not try further
237-
break
238-
if path != this_module_path: # Not inside the objectbox package
239-
calling_module = module
240-
break
239+
frame = inspect.currentframe()
240+
try:
241+
while frame is not None:
242+
module = sys.modules.get(frame.f_globals.get("__name__"))
243+
if module is not this_module:
244+
path = get_module_path(module)
245+
if not path: # Cannot get the direct caller's path, so do not try further
246+
break
247+
if path != this_module_path: # Not inside the objectbox package
248+
calling_module = module
249+
break
250+
frame = frame.f_back
251+
finally:
252+
del frame
241253
model_json_file = json_file_inside_module_path(calling_module)
242254

243255
if not model_json_file:

‎tests/test_basics.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14+
import gc
15+
import weakref
16+
1417
import objectbox
1518
from objectbox.exceptions import DbError, NotFoundError
1619
from tests.common import create_test_store
@@ -35,6 +38,23 @@ def test_open():
3538
store.close()
3639

3740

41+
def test_store_closed_when_dropped():
42+
"""A store that is no longer referenced must be closed right away, without relying on the cyclic GC."""
43+
gc_was_enabled = gc.isenabled()
44+
gc.disable()
45+
try:
46+
store = create_test_store()
47+
store_ref = weakref.ref(store)
48+
del store
49+
assert store_ref() is None
50+
# Reopen without deleting the DB files first; that would hide a still open store on Linux (not on Windows)
51+
store = create_test_store(clear_db=False) # Raises if the previous store at the same path is still open
52+
store.close()
53+
finally:
54+
if gc_was_enabled:
55+
gc.enable()
56+
57+
3858
def test_not_found_exception():
3959
try:
4060
raise NotFoundError()

‎tests/test_idsync.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,10 @@ class EntityB2:
589589
# This might require to store the (Python) model in the Store.
590590
# with pytest.raises(ValueError):
591591
# store_b.box(EntityA)
592+
store_a.close()
593+
store_b.close()
594+
595+
592596
def test_sync_dynamic_entities(env):
593597
def create_entity(entity_name: str, dimensions: int, distance_type: VectorDistanceType, uid=0):
594598
DynamicEntity = type(entity_name, (), {

‎tests/test_store_options.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,5 @@ def mylog(level: OBXLogLevel, message: str):
104104
assert log_entries[0][1].startswith("Opening store:")
105105
assert log_entries[1][0] == LogLevel.Debug
106106
assert log_entries[1][1].startswith("Opening store:")
107+
store.close()
107108

‎tests/test_userclass.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ def tick(self):
4242
id_empty = box.put(Person())
4343
empty = box.get(id_empty)
4444
assert empty.fullname() == " "
45+
store.close()

0 commit comments

Comments
 (0)