Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion tests/integration/test_sqlalchemy_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,10 +751,16 @@ def test_version_is_lazy(trino_connection):
assert isinstance(version_info, tuple)
num_queries = _num_queries_containing_string(conn, "SELECT version()")
assert num_queries == 1
# Reading server_version_info again should be served from the cache and must
# not issue another SELECT version() query.
version_info = conn.dialect.server_version_info
assert isinstance(version_info, tuple)
num_queries = _num_queries_containing_string(conn, "SELECT version()")
assert num_queries == 1


def _num_queries_containing_string(connection, query_string):
statement = sqla.text("select query from system.runtime.queries order by query_id desc offset 1 limit 1")
statement = sqla.text("select query from system.runtime.queries")
result = connection.execute(statement)
rows = result.fetchall()
return len(list(filter(lambda rec: query_string in rec[0], rows)))
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/sqlalchemy/test_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,46 @@ def test_trino_connection_oauth2_auth():
_, cparams = dialect.create_connect_args(url)

assert isinstance(cparams['auth'], OAuth2Authentication)


def test_server_version_info_does_not_recurse_and_is_cached():
# Regression test for https://github.com/trinodb/trino-python-client/issues/559
#
# aws-xray-sdk reads `dialect.server_version_info` before every execute. Since
# computing that property itself issues a `SELECT version()` query through
# `connection.execute`, a naive implementation causes aws-xray to read the
# property again while the query is in flight, recursing forever.
dialect = TrinoDialect()

class FakeResult:
def __init__(self, value):
self._value = value

def scalar(self):
return self._value

class FakeConnection:
def __init__(self, dialect):
self.dialect = dialect
self.execute_count = 0

def execute(self, *args, **kwargs):
# Mimic aws-xray-sdk's re-entrant read of server_version_info before
# every execute call.
self.dialect.server_version_info
self.execute_count += 1
return FakeResult("455")

fake_conn = FakeConnection(dialect)

dialect._get_server_version_info(fake_conn)

version_info = dialect.server_version_info
assert version_info == ("455",)
assert fake_conn.execute_count == 1

# Subsequent reads should be served from the cache and must not trigger
# another query.
assert dialect.server_version_info == ("455",)
assert dialect.server_version_info == ("455",)
assert fake_conn.execute_count == 1
14 changes: 11 additions & 3 deletions trino/sqlalchemy/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,15 +421,23 @@ def has_sequence(self, connection: Connection, sequence_name: str, schema: str =

@classmethod
def _get_server_version_info(cls, connection: Connection) -> Any:
def get_server_version_info(_):
def get_server_version_info(self):
if "_trino_server_version_info" in self.__dict__:
return self.__dict__["_trino_server_version_info"]
# Seed the cache before running the query so that a re-entrant read
# triggered while the version query is in flight returns immediately
# instead of recursing infinitely.
self.__dict__["_trino_server_version_info"] = None
query = "SELECT version()"
try:
res = connection.execute(sql.text(query))
version = res.scalar()
return tuple([version])
value = tuple([version])
except exc.ProgrammingError as e:
logger.debug(f"Failed to get server version: {e.orig.message}")
return None
value = None
self.__dict__["_trino_server_version_info"] = value
Comment on lines 436 to +439

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Crash risk and incorrect logging when accessing e.orig.message.

Accessing e.orig.message introduces two significant failure modes:

  1. Crash risk: If the underlying DBAPI exception (e.orig) does not have a message attribute (which is common for standard DBAPI exceptions), this will raise an AttributeError. Since AttributeError is not caught here, it will bubble up and crash the application during the version lookup.
  2. Incorrect logging: For TrinoQueryError where message is defined as a method (def message(self) -> str:), omitting the parentheses causes the f-string to evaluate and log the bound method object (e.g., <bound method ...>) instead of the actual error message.

To fix both issues and robustly handle any underlying DBAPI exception type, use str(e) or e directly.

🛠️ Proposed fix to safely log the exception
-            except exc.ProgrammingError as e:
-                logger.debug(f"Failed to get server version: {e.orig.message}")
-                value = None
+            except exc.ProgrammingError as e:
+                logger.debug("Failed to get server version: %s", e)
+                value = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except exc.ProgrammingError as e:
logger.debug(f"Failed to get server version: {e.orig.message}")
return None
value = None
self.__dict__["_trino_server_version_info"] = value
except exc.ProgrammingError as e:
logger.debug("Failed to get server version: %s", e)
value = None
self.__dict__["_trino_server_version_info"] = value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trino/sqlalchemy/dialect.py` around lines 436 - 439, Update the
ProgrammingError handler in the server-version lookup to log the caught
exception via its string representation, such as e or str(e), instead of
accessing e.orig.message. Preserve the existing debug logging context and value
= None fallback.

return value

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Avoid mutating the class and capturing transient initialization connections.

This lazy evaluation approach introduces three severe architectural hazards across SQLAlchemy engines and threads:

  1. Class Mutation: Assigning cls.server_version_info = property(...) mutates the TrinoDialect class globally. All engine instances using this dialect will share the exact same property.
  2. Connection Leak/Hazard: The get_server_version_info closure captures the specific connection passed during the very first engine's initialization. When the property is evaluated later by an application (or an interception tool like aws-xray-sdk), it will attempt to execute SELECT version() on that originally captured connection. If that connection has been returned to the pool or closed, it can lead to "connection closed" errors or interfere with other threads currently using that pooled connection.
  3. Thread Race Condition: Since a single TrinoDialect instance is shared across all connections in an engine pool, setting self.__dict__["_trino_server_version_info"] = None creates a race condition. If Thread A triggers the lookup, it blocks on I/O. If Thread B accesses server_version_info concurrently, it will immediately receive None from the cache instead of the real version, causing Thread B to operate with missing version data.

If the overhead of a single SELECT version() during Engine initialization is acceptable, the safest and most robust fix is to remove the lazy property entirely and perform the eager evaluation that SQLAlchemy fundamentally expects:

    `@classmethod`
    def _get_server_version_info(cls, connection: Connection) -> Any:
        query = "SELECT version()"
        try:
            res = connection.execute(sql.text(query))
            version = res.scalar()
            return (version,)
        except exc.ProgrammingError as e:
            logger.debug("Failed to get server version: %s", e)
            return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trino/sqlalchemy/dialect.py` around lines 423 - 443, Remove the lazy property
assignment and nested get_server_version_info closure from
_get_server_version_info. Restore eager evaluation using the provided
connection: execute SELECT version(), return the version as a one-element tuple,
and return None on exc.ProgrammingError while logging the exception without
relying on e.orig.message. Do not mutate cls.server_version_info or cache
results on the dialect instance.

Expand Down
Loading