Cache server_version_info and guard against re-entrant reads#622
Cache server_version_info and guard against re-entrant reads#622hashhar wants to merge 1 commit into
Conversation
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.
387b737 to
2452316
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
tests/integration/test_sqlalchemy_integration.py (1)
744-760: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueTest will fail if eager evaluation is adopted.
If you refactor
dialect.pyto use eager evaluation as recommended, this test will fail becauseSELECT version()will be executed during dialect initialization (meaningnum_querieswill be 1 immediately after connection, beforeserver_version_infois explicitly accessed).If eager evaluation is adopted, this test should either be removed or repurposed to simply verify that
server_version_infois correctly populated and cached once per engine.🤖 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 `@tests/integration/test_sqlalchemy_integration.py` around lines 744 - 760, The test_version_is_lazy test assumes lazy server-version evaluation and will fail if dialect initialization becomes eager. Repurpose or remove its pre-access query-count assertions, and instead verify that dialect.server_version_info is correctly populated and that repeated access does not issue more than one SELECT version() query per engine.tests/unit/sqlalchemy/test_dialect.py (1)
319-359: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueTest may be obsolete if eager evaluation is adopted.
This test correctly validates the new caching logic designed to prevent recursive execution. However, as noted in
dialect.py, the current lazy property implementation introduces significant concurrency and connection-leak risks.If you adopt the recommended eager evaluation approach, the re-entrant hazard natively disappears. In that case, this test should be simplified to just verify that
_get_server_version_inforeturns the expected tuple and handles exceptions gracefully, without needing to mimic re-entrant reads or use custom property getters.🤖 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 `@tests/unit/sqlalchemy/test_dialect.py` around lines 319 - 359, Update test_server_version_info_does_not_recurse_and_is_cached to match the eager server-version evaluation approach: remove the re-entrant FakeConnection/property-read simulation and cache-specific execute-count assertions, and instead verify that _get_server_version_info returns the expected version tuple and handles exceptions gracefully.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@trino/sqlalchemy/dialect.py`:
- Around line 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.
- Around line 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.
---
Nitpick comments:
In `@tests/integration/test_sqlalchemy_integration.py`:
- Around line 744-760: The test_version_is_lazy test assumes lazy server-version
evaluation and will fail if dialect initialization becomes eager. Repurpose or
remove its pre-access query-count assertions, and instead verify that
dialect.server_version_info is correctly populated and that repeated access does
not issue more than one SELECT version() query per engine.
In `@tests/unit/sqlalchemy/test_dialect.py`:
- Around line 319-359: Update
test_server_version_info_does_not_recurse_and_is_cached to match the eager
server-version evaluation approach: remove the re-entrant
FakeConnection/property-read simulation and cache-specific execute-count
assertions, and instead verify that _get_server_version_info returns the
expected version tuple and handles exceptions gracefully.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d7f0c464-5106-4a8c-9877-ba7c247c20e0
📒 Files selected for processing (3)
tests/integration/test_sqlalchemy_integration.pytests/unit/sqlalchemy/test_dialect.pytrino/sqlalchemy/dialect.py
| 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 | ||
| 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) |
There was a problem hiding this comment.
🩺 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:
- Class Mutation: Assigning
cls.server_version_info = property(...)mutates theTrinoDialectclass globally. All engine instances using this dialect will share the exact same property. - Connection Leak/Hazard: The
get_server_version_infoclosure captures the specificconnectionpassed during the very first engine's initialization. When the property is evaluated later by an application (or an interception tool likeaws-xray-sdk), it will attempt to executeSELECT 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. - Thread Race Condition: Since a single
TrinoDialectinstance is shared across all connections in an engine pool, settingself.__dict__["_trino_server_version_info"] = Nonecreates a race condition. If Thread A triggers the lookup, it blocks on I/O. If Thread B accessesserver_version_infoconcurrently, it will immediately receiveNonefrom 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.
| 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 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Crash risk and incorrect logging when accessing e.orig.message.
Accessing e.orig.message introduces two significant failure modes:
- Crash risk: If the underlying DBAPI exception (
e.orig) does not have amessageattribute (which is common for standard DBAPI exceptions), this will raise anAttributeError. SinceAttributeErroris not caught here, it will bubble up and crash the application during the version lookup. - Incorrect logging: For
TrinoQueryErrorwheremessageis 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.
| 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.
Description
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.
Non-technical explanation
Cache server_version_info and guard against re-entrant reads. Fixes #559.
Release notes
( ) This is not user-visible or docs only and no release notes are required.
(x) Release notes are required, please propose a release note for me.
( ) Release notes are required, with the following suggested text: