Skip to content

fix(sqlalchemy-spanner): isolate literal round trip test tables with unique naming and teardown - #18623

Draft
chalmerlowe wants to merge 2 commits into
mainfrom
fix/sqlalchemy-spanner-literal-collision
Draft

chalmerlowe wants to merge 2 commits into
mainfrom
fix/sqlalchemy-spanner-literal-collision

Conversation

@chalmerlowe

@chalmerlowe chalmerlowe commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fix Schema Collision and DDL Concurrency in SQLAlchemy Spanner Literal Tests

Problem

In the Cloud Spanner SQLAlchemy compliance tests, tests using the literal_round_trip_spanner fixture were failing with 400 Duplicate name in schema: t_literal_round_trip_spanner.

This occurred because the fixture created a table with a static, hardcoded name (t_literal_round_trip_spanner) without teardown between tests. When subsequent test methods in the same class (such as StringTest, NumericTest, BooleanTest, and TextTest) invoked the fixture, Cloud Spanner rejected creating an already existing table. Furthermore, executing DDL statements while read-write transactions from queries remain active triggers 400 Schema change operation rejected because a concurrent schema change operation or read-write transaction is already in progress.

Solution

  1. Dynamic Unique Table Naming: Generated an isolated table name per test invocation using the column type and a random hexadecimal suffix (for example, t_lit_string_a3f2b1).
  2. Cross-Version DBAPI Resolution: Retrieved dbapi_connection first with fallback to connection to support both modern SQLAlchemy 2.0 and legacy 1.4 without deprecation warnings.
  3. Transaction State Management: Added explicit rollback of active transactions before executing DDL drops, ensuring Cloud Spanner does not reject table drops due to concurrent active transactions.
  4. Guaranteed Metadata Teardown: Enclosed table creation in the primary try block and table drop in a nested try...finally block to ensure that table entries are always evicted from SQLAlchemy metadata even if creation or drop operations fail.

Related Pull Requests

This fix unblocks and resolves failing Kokoro Sqlalchemy Compliance Tests across the following pull requests once rebased onto main:

Notes to Reviewers

  • This change is strictly localized to packages/sqlalchemy-spanner/tests/conftest.py.
  • It provides test isolation for literal rendering tests, eliminating schema collisions and transaction concurrency failures without modifying runtime dialect behavior.

…unique naming and teardown

Prevent schema collisions ('400 Duplicate name in schema') across test
invocations by generating unique table names per test execution and
guaranteeing table teardown with committed DDL and MetaData eviction.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request enhances test isolation in the literal_round_trip_spanner fixture by generating unique table names using UUIDs, explicitly committing DDL operations, and adding a finally block to drop the table and remove it from metadata. The review feedback suggests three improvements: using dbapi_connection with a fallback to support SQLAlchemy 2.0 compatibility, moving the table creation inside the try block to ensure cleanup if creation fails, and wrapping the table drop in a nested try...finally block to guarantee that the table is always removed from metadata even if the drop operation itself raises an exception.


def _commit_ddl(connection):
"""Flush and commit buffered DDL in Spanner DBAPI or underlying connections."""
dbapi_conn = getattr(connection, "connection", connection)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In SQLAlchemy 2.0, Connection.connection is deprecated in favor of Connection.dbapi_connection. To ensure compatibility with both SQLAlchemy 1.4 and 2.0 without triggering deprecation warnings, retrieve dbapi_connection first, falling back to connection if it is not present.

Suggested change
dbapi_conn = getattr(connection, "connection", connection)
dbapi_conn = getattr(connection, "dbapi_connection", getattr(connection, "connection", connection))

Comment on lines +99 to +102
t.create(connection)

for value in input_:
ins = t.insert().values(x=literal(value, type_))
connection.execute(ins)

if support_whereclause:
if compare:
stmt = t.select().where(
t.c.x
== literal(
compare,
type_,
),
t.c.x
== literal(
input_[0],
type_,
),
)
else:
stmt = t.select().where(
t.c.x
== literal(
compare if compare is not None else input_[0],
type_,
_commit_ddl(connection)

try:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If t.create(connection) fails or raises an exception, the try...finally block is never entered. Since Table automatically registers itself in the metadata object upon instantiation, this leaves a stale table reference in metadata and can leak the table in the database if the creation partially succeeded. Wrapping the table creation inside the try block ensures that the cleanup in the finally block is always executed.

Suggested change
t.create(connection)
for value in input_:
ins = t.insert().values(x=literal(value, type_))
connection.execute(ins)
if support_whereclause:
if compare:
stmt = t.select().where(
t.c.x
== literal(
compare,
type_,
),
t.c.x
== literal(
input_[0],
type_,
),
)
else:
stmt = t.select().where(
t.c.x
== literal(
compare if compare is not None else input_[0],
type_,
_commit_ddl(connection)
try:
try:
t.create(connection)
_commit_ddl(connection)

Comment on lines +140 to +143
finally:
t.drop(connection, checkfirst=True)
_commit_ddl(connection)
metadata.remove(t)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If t.drop(connection) or _commit_ddl(connection) raises an exception (e.g., due to connection issues), metadata.remove(t) will be bypassed, leaving a stale table reference in metadata. Wrapping the drop in a nested try...finally guarantees that metadata.remove(t) is always executed.

Suggested change
finally:
t.drop(connection, checkfirst=True)
_commit_ddl(connection)
metadata.remove(t)
finally:
try:
t.drop(connection, checkfirst=True)
_commit_ddl(connection)
finally:
metadata.remove(t)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant