Skip to content

Commit d4d3715

Browse files
committed
Cache server_version_info and guard against re-entrant reads
The SQLAlchemy dialect exposed server_version_info as an un-cached property whose getter runs "SELECT version()" through the connection. Because the query executes via the same SQLAlchemy machinery any code that reads server_version_info while a query is in flight re-enters the getter and issues another query, recursing forever. aws-xray-sdk is an example of this pattern. Cache the resolved value on the dialect instance and seed the cache with None before issuing the query. A re-entrant read that arrives while the version query is in flight now returns immediately from the seeded cache instead of recursing and subsequent reads are served from the cache without additional HTTP calls.
1 parent 5dbb996 commit d4d3715

3 files changed

Lines changed: 149 additions & 11 deletions

File tree

tests/integration/test_sqlalchemy_integration.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,18 +818,27 @@ def test_get_view_names_raises(trino_connection):
818818
@pytest.mark.skipif(trino_version() == 351, reason="version() not supported in older Trino versions")
819819
def test_version_is_lazy(trino_connection):
820820
_, conn = trino_connection
821+
# The suite can run against a reused, long-lived cluster. Count queries relative to
822+
# what the server already has in its history.
823+
baseline = _num_queries_containing_string(conn, "SELECT version()")
821824
result = conn.execute(sqla.text("SELECT 1"))
822825
result.fetchall()
823826
num_queries = _num_queries_containing_string(conn, "SELECT version()")
824-
assert num_queries == 0
827+
assert num_queries == baseline
825828
version_info = conn.dialect.server_version_info
826829
assert isinstance(version_info, tuple)
827830
num_queries = _num_queries_containing_string(conn, "SELECT version()")
828-
assert num_queries == 1
831+
assert num_queries == baseline + 1
832+
# Reading server_version_info again should be served from the cache and must
833+
# not issue another SELECT version() query.
834+
version_info = conn.dialect.server_version_info
835+
assert isinstance(version_info, tuple)
836+
num_queries = _num_queries_containing_string(conn, "SELECT version()")
837+
assert num_queries == baseline + 1
829838

830839

831840
def _num_queries_containing_string(connection, query_string):
832-
statement = sqla.text("select query from system.runtime.queries order by query_id desc offset 1 limit 1")
841+
statement = sqla.text("select query from system.runtime.queries")
833842
result = connection.execute(statement)
834843
rows = result.fetchall()
835844
return len(list(filter(lambda rec: query_string in rec[0], rows)))

tests/unit/sqlalchemy/test_dialect.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import threading
2+
import time
13
from typing import Any
24
from typing import Dict
35
from typing import List
@@ -349,3 +351,109 @@ def test_url_rejects_non_hostname_host(host):
349351
def test_url_accepts_bare_hostname(host):
350352
# A valid host must round-trip through make_url without error.
351353
assert make_url(trino_url(host=host, port=443, user="user")).host is not None
354+
355+
356+
def test_server_version_info_does_not_recurse_and_is_cached():
357+
# Regression test for https://github.com/trinodb/trino-python-client/issues/559
358+
#
359+
# aws-xray-sdk reads `dialect.server_version_info` before every execute. Since
360+
# computing that property itself issues a `SELECT version()` query through
361+
# `connection.execute`, a naive implementation causes aws-xray to read the
362+
# property again while the query is in flight, recursing forever.
363+
dialect = TrinoDialect()
364+
365+
class FakeResult:
366+
def __init__(self, value):
367+
self._value = value
368+
369+
def scalar(self):
370+
return self._value
371+
372+
class FakeConnection:
373+
def __init__(self, dialect):
374+
self.dialect = dialect
375+
self.execute_count = 0
376+
377+
def execute(self, *args, **kwargs):
378+
# Mimic aws-xray-sdk's re-entrant read of server_version_info before
379+
# every execute call.
380+
self.dialect.server_version_info
381+
self.execute_count += 1
382+
return FakeResult("455")
383+
384+
fake_conn = FakeConnection(dialect)
385+
386+
dialect._get_server_version_info(fake_conn)
387+
388+
version_info = dialect.server_version_info
389+
assert version_info == ("455",)
390+
assert fake_conn.execute_count == 1
391+
392+
# Subsequent reads should be served from the cache and must not trigger
393+
# another query.
394+
assert dialect.server_version_info == ("455",)
395+
assert dialect.server_version_info == ("455",)
396+
assert fake_conn.execute_count == 1
397+
398+
399+
def test_server_version_info_is_resolved_for_concurrent_readers():
400+
# The re-entrancy guard must be scoped to the thread doing the lookup. A reader on another
401+
# thread has to wait for the real version instead of observing the in-flight placeholder.
402+
dialect = TrinoDialect()
403+
query_in_flight = threading.Event()
404+
release_query = threading.Event()
405+
406+
class FakeResult:
407+
def __init__(self, value):
408+
self._value = value
409+
410+
def scalar(self):
411+
return self._value
412+
413+
class FakeConnection:
414+
def __init__(self):
415+
self.execute_count = 0
416+
417+
def execute(self, *args, **kwargs):
418+
self.execute_count += 1
419+
query_in_flight.set()
420+
release_query.wait(timeout=10)
421+
return FakeResult("455")
422+
423+
fake_conn = FakeConnection()
424+
dialect._get_server_version_info(fake_conn)
425+
426+
results = {}
427+
428+
def read(name):
429+
results[name] = dialect.server_version_info
430+
431+
first = threading.Thread(target=read, args=("first",))
432+
second = threading.Thread(target=read, args=("second",))
433+
434+
first.start()
435+
assert query_in_flight.wait(timeout=10)
436+
second.start()
437+
# Give the second thread a chance to reach the getter while the query is still in flight.
438+
time.sleep(0.1)
439+
release_query.set()
440+
441+
first.join(timeout=10)
442+
second.join(timeout=10)
443+
444+
assert results == {"first": ("455",), "second": ("455",)}
445+
assert fake_conn.execute_count == 1
446+
447+
448+
def test_server_version_info_handles_orig_without_message_attribute():
449+
# Only TrinoQueryError exposes `.message`. The DBAPI error wrapped by SQLAlchemy could be
450+
# any exception. Failing to read the version must not raise AttributeError from the log call.
451+
dialect = TrinoDialect()
452+
453+
class FailingConnection:
454+
def execute(self, *args, **kwargs):
455+
raise exc.ProgrammingError("SELECT version()", None, Exception("boom"))
456+
457+
dialect._get_server_version_info(FailingConnection())
458+
459+
assert dialect.server_version_info is None

trino/sqlalchemy/dialect.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212
import json
13+
import threading
1314
from collections.abc import Mapping
1415
from collections.abc import Sequence
1516
from textwrap import dedent
@@ -423,15 +424,35 @@ def has_sequence(self, connection: Connection, sequence_name: str, schema: str =
423424

424425
@classmethod
425426
def _get_server_version_info(cls, connection: Connection) -> Any:
426-
def get_server_version_info(_):
427-
query = "SELECT version()"
428-
try:
429-
res = connection.execute(sql.text(query))
430-
version = res.scalar()
431-
return tuple([version])
432-
except exc.ProgrammingError as e:
433-
logger.debug(f"Failed to get server version: {e.orig.message}")
427+
lock = threading.Lock()
428+
resolving = threading.local()
429+
430+
def get_server_version_info(self):
431+
if "_trino_server_version_info" in self.__dict__:
432+
return self.__dict__["_trino_server_version_info"]
433+
# The version query goes through the same SQLAlchemy machinery as any other query. A caller
434+
# that reads this property before every execute re-enters the getter while that query is
435+
# still in flight. Recursing there would never terminate, so the re-entrant read returns
436+
# None instead. Reads from other threads are not re-entrant. Those wait on the lock and
437+
# get the real value.
438+
if getattr(resolving, "in_progress", False):
434439
return None
440+
with lock:
441+
if "_trino_server_version_info" in self.__dict__:
442+
return self.__dict__["_trino_server_version_info"]
443+
resolving.in_progress = True
444+
query = "SELECT version()"
445+
try:
446+
res = connection.execute(sql.text(query))
447+
version = res.scalar()
448+
value = tuple([version])
449+
except exc.ProgrammingError as e:
450+
logger.debug("Failed to get server version: %s", e)
451+
value = None
452+
finally:
453+
resolving.in_progress = False
454+
self.__dict__["_trino_server_version_info"] = value
455+
return value
435456

436457
# Make server_version_info lazy in order to only make HTTP calls if user explicitly requests it.
437458
cls.server_version_info = property(get_server_version_info, lambda instance, value: None)

0 commit comments

Comments
 (0)