Repository navigation
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #889 +/- ##
==========================================
+ Coverage 95.43% 95.46% +0.02%
==========================================
Files 134 134
Lines 26843 27020 +177
==========================================
+ Hits 25618 25795 +177
Misses 1225 1225 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Codecov reported 90% patch coverage for PR #889 with 6 uncovered new lines in rope/contrib/autoimport/sqlite.py: - the guard branch in _register_connection() that closes the new connection and raises when the instance is already closed - the closed-instance guard in the `connection` setter - the setter path discarding the previously tracked thread-local connection when replaced by a different one Add tests hitting each of these paths so the patch coverage is complete.
|
I ran the lifetime tests from #888 against this branch (5756c12), plus two extra cases mcepl asked about. Results:
def test_connections_of_finished_threads_are_closed(project: Project):
with closing(AutoImport(project, memory=True)) as autoimport:
with ThreadPoolExecutor(1) as tp:
worker_connection = tp.submit(lambda: autoimport.connection).result()
# The next new connection closes the ones left by finished threads
with ThreadPoolExecutor(1) as tp:
tp.submit(lambda: autoimport.connection).result()
with pytest.raises(sqlite3.ProgrammingError, match="closed database"):
worker_connection.execute("SELECT 1")The retention also adds up: after 20 short-lived threads, each touching #888 handles this by keying the registry by One minor point: the Tested on Python 3.14, Windows. Once the exited-thread test (or an equivalent) is in this PR, I'm happy to close #888. |
|
I haven't tested this, but if avoiding the warning is all that's needed, I think we can simplify the implementation of this connection closing mechanism. If we put a ConnectionCloser object into the thread local, then when the thread is finished, it will garbage collect the thread_local, which would garbage collect the thread closer, which can close the sqlite connection: class ConnectionCloser:
def __init__(self, connection):
self.connection = connection
def __del__(self):
self.connection.close()@property
def connection(self):
"""
Creates a new connection if called from a new thread.
This makes sure AutoImport can be shared across threads.
"""
if not hasattr(self.thread_local, "connection"):
self.thread_local.connection = self.create_database_connection(
project=self.project,
memory=self.memory,
)
self.thread_local.closer = ConnectionCloser(self.thread_local.connection)
return self.thread_local.connectionThe connection closer should run on the same thread as the connection object, so it would not need to bypass And I think a global I'd really prefer not to have to bypass the Ideally, if worker threads don't want their connection objects to stay alive till the end of their thread, it should be that thread's responsibility to enter a context manager to manage the lifetime of their connection. My preference for AutoImport's concurrency model when the thread_local was originally implemented was that each worker threads should usually be creating their own instance of AutoImport. The use of thread local here means that each threads can have their own connection, but then they weren't required to manage the lifetime of their connection. I'll need to let this simmer in my head for a bit. |
|
I agree that each worker owning its own I am not sure a Would that approach address your concern, or would you prefer a different ownership model for these connections? |
5756c12 to
fb4883b
Compare
Codecov reported 90% patch coverage for PR #889 with 6 uncovered new lines in rope/contrib/autoimport/sqlite.py: - the guard branch in _register_connection() that closes the new connection and raises when the instance is already closed - the closed-instance guard in the `connection` setter - the setter path discarding the previously tracked thread-local connection when replaced by a different one Add tests hitting each of these paths so the patch coverage is complete.
Codecov reported 90% patch coverage for PR #889 with 6 uncovered new lines in rope/contrib/autoimport/sqlite.py: - the guard branch in _register_connection() that closes the new connection and raises when the instance is already closed - the closed-instance guard in the `connection` setter - the setter path discarding the previously tracked thread-local connection when replaced by a different one Add tests hitting each of these paths so the patch coverage is complete.
fb4883b to
1ea2f26
Compare
|
Ok, so I just tested this, the ConnectionCloser + finalize() implementationdiff --git a/rope/contrib/autoimport/sqlite.py b/rope/contrib/autoimport/sqlite.py
index 7ae8fb71..014ca6b8 100644
--- a/rope/contrib/autoimport/sqlite.py
+++ b/rope/contrib/autoimport/sqlite.py
@@ -9,6 +9,7 @@ import secrets
import sqlite3
import sys
import warnings
+import weakref
from collections import OrderedDict
from concurrent.futures import Future, ProcessPoolExecutor, as_completed
from datetime import datetime
@@ -143,10 +144,6 @@ class AutoImport:
DeprecationWarning,
)
self.thread_local = local()
- self.connection = self.create_database_connection(
- project=project,
- memory=memory,
- )
self._setup_db()
if observe:
observer = resourceobserver.ResourceObserver(
@@ -186,10 +183,15 @@ class AutoImport:
else:
project_hash = calculate_project_hash(project.ropefolder.real_path)
return sqlite3.connect(
- f"file:rope-{project_hash}:?mode=memory&cache=shared", uri=True
+ f"file:rope-{project_hash}:?mode=memory&cache=shared",
+ uri=True,
+ check_same_thread=False,
)
else:
- return sqlite3.connect(project.ropefolder.pathlib / "autoimport.db")
+ return sqlite3.connect(
+ project.ropefolder.pathlib / "autoimport.db",
+ check_same_thread=False,
+ )
@property
def connection(self) -> sqlite3.Connection:
@@ -203,6 +205,11 @@ class AutoImport:
project=self.project,
memory=self.memory,
)
+ self.thread_local.connection_finalizer = weakref.finalize(
+ self.thread_local,
+ self.thread_local.connection.close,
+ )
+
return self.thread_local.connection
@connection.setter
@@ -457,10 +464,17 @@ class AutoImport:
self._del_if_exist(module)
self.generate_modules_cache([module])
+ def close_all(self):
+ """Close autoimport for all threads."""
+ with contextlib.suppress(AttributeError):
+ # deleting the thread_local would run all the associated
+ # connection finalizers
+ del self.thread_local
+
def close(self):
- """Close the autoimport database."""
+ """Close autoimport for the current thread."""
self.connection.commit()
- self.connection.close()
+ self.thread_local.connection_finalizer()
def get_name_locations(self, name):
"""Return a list of ``(resource, lineno)`` tuples."""The problem is that just like any other approaches that wants to have a global close connection method, they are all going to still require Putting aside the issue of whether explicit or implicit registry, the more fundamental issue is that closing a connection from a different thread is inherently risky in sqlite3. If you segfault POCimport threading
import sqlite3
import time
def worker():
sql = 'SELECT sleep(5)'
print('worker: going to sleep')
conn.execute(sql)
conn = sqlite3.connect(':memory:', check_same_thread=False)
conn.create_function('sleep', 1, time.sleep)
time.sleep(1)
t = threading.Thread(target=worker)
t.start()
print('main: closing connection')
conn.close()
I'm not quite sure there are any safe way to In Python, usually that means that either the workers would have to acquire a context manager before performing any operations, or the main thread has to arrange such that all the workers are already finished by the time the main thread is closing the connections. |
|
Following up on the connection-lifetime discussion. I pushed c4c0eca with a thread-to-connection registry that reaps connections only after their owner thread exits. I also removed the Does this shutdown contract address the concern? |
AutoImport dynamically allocates per-thread SQLite connections using threading.local(). However, AutoImport.close() only closed the connection belonging to the calling thread, orphaning any connections created by worker threads and triggering ResourceWarning on Python 3.12+. Keep a thread-safe registry of all active connections on the AutoImport instance, create connections with check_same_thread=False, and ensure AutoImport.close() commits and closes all registered connections. Also expose close_thread_connection() for worker threads and implement context manager protocol support on AutoImport. Fixes: #886
Codecov reported 90% patch coverage for PR #889 with 6 uncovered new lines in rope/contrib/autoimport/sqlite.py: - the guard branch in _register_connection() that closes the new connection and raises when the instance is already closed - the closed-instance guard in the `connection` setter - the setter path discarding the previously tracked thread-local connection when replaced by a different one Add tests hitting each of these paths so the patch coverage is complete.
Associate each connection with its owner thread and close it only after that thread exits. Reject AutoImport.close() while another live thread owns a connection, and document the shutdown requirement. Add tests for worker connection reaping and refused concurrent close.
c4c0eca to
1306192
Compare
AutoImport dynamically allocates per-thread SQLite connections using threading.local(). However, AutoImport.close() only closed the connection belonging to the calling thread, orphaning any connections created by worker threads and triggering ResourceWarning on Python 3.12+.
Keep a thread-safe registry of all active connections on the AutoImport instance, create connections with check_same_thread=False, and ensure AutoImport.close() commits and closes all registered connections. Also expose close_thread_connection() for worker threads and implement context manager protocol support on AutoImport.
Fixes: #886
Checklist (delete if not relevant):