Skip to content

fix(sqlalchemy-spanner): robust session-isolated test databases and clean lifecycle management - #18179

Draft
chalmerlowe wants to merge 1 commit into
mainfrom
refactor/sqlalchemy-spanner-clean-isolation
Draft

fix(sqlalchemy-spanner): robust session-isolated test databases and clean lifecycle management#18179
chalmerlowe wants to merge 1 commit into
mainfrom
refactor/sqlalchemy-spanner-clean-isolation

Conversation

@chalmerlowe

Copy link
Copy Markdown
Contributor

Clean, uncluttered implementation isolating SQLAlchemy Spanner test databases, complying with Spanner naming rules, and DRYing up session lifecycles using a clean context manager.

@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 introduces isolated, parallel test sessions for the Spanner SQLAlchemy dialect by dynamically provisioning unique test databases and configuration files. Key feedback from the review highlights critical issues with duplicate database creation in create_test_database.py that leads to leaked databases, potential race conditions from using a static session metadata file (recommending session-isolated metadata files and path normalization), environment variable pollution from os.environ.setdefault in noxfile.py, and an unused configuration variable in the system test session.

Comment on lines +163 to +192
def main(argv):
config_filename = argv[0] if argv else os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg")
os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_filename

delete_stale_test_databases()
create_test_instance()

instance_id = "sqlalchemy-dialect-test"
instance = CLIENT.instance(instance_id)

# Generate a session-isolated unique database ID within Spanner 30-char limit
# Format: sp_test_{timestamp_in_seconds}_{rand_hex8} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$)
creation_timestamp = time.time()
timestamp_part = str(int(creation_timestamp))
rand_part = uuid.uuid4().hex[:8]
database_id = f"sp_test_{timestamp_part}_{rand_part}"

try:
database = instance.database(database_id)
created_op = database.create()
created_op.result(1800)
except AlreadyExists:
pass # database was already created

set_test_config(PROJECT, instance_id, database_id)

# Record metadata for duration tracking on teardown
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
with open(meta_path, "w") as f:
json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f)

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.

critical

The main function calls create_test_instance(), which already provisions the test database, writes the configuration file, and records the session metadata. The subsequent code in main duplicates this entire process, creating a second database and overwriting both the configuration and metadata files. This leaves the first database orphaned and leaked, which will quickly exhaust Spanner's limit of 100 databases per instance.

We should simplify main to only call create_test_instance().

Suggested change
def main(argv):
config_filename = argv[0] if argv else os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg")
os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_filename
delete_stale_test_databases()
create_test_instance()
instance_id = "sqlalchemy-dialect-test"
instance = CLIENT.instance(instance_id)
# Generate a session-isolated unique database ID within Spanner 30-char limit
# Format: sp_test_{timestamp_in_seconds}_{rand_hex8} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$)
creation_timestamp = time.time()
timestamp_part = str(int(creation_timestamp))
rand_part = uuid.uuid4().hex[:8]
database_id = f"sp_test_{timestamp_part}_{rand_part}"
try:
database = instance.database(database_id)
created_op = database.create()
created_op.result(1800)
except AlreadyExists:
pass # database was already created
set_test_config(PROJECT, instance_id, database_id)
# Record metadata for duration tracking on teardown
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
with open(meta_path, "w") as f:
json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f)
def main(argv):
config_filename = argv[0] if argv else os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg")
os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_filename
delete_stale_test_databases()
create_test_instance()
References
  1. Remove duplicate lines of code, especially duplicate assertions in tests, to keep the codebase clean and avoid redundancy.

Comment on lines +157 to +160
# Record metadata for duration tracking on teardown
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
with open(meta_path, "w") as f:
json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f)

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.

high

Writing to a static .db_session_info.json file can cause race conditions and overwrites when running multiple test sessions in parallel (e.g., via nox).

Since each session has a unique SQLALCHEMY_SPANNER_CONFIG filename, we can derive a session-isolated metadata filename from it to prevent concurrent sessions from interfering with each other. We also normalize the path using os.path.normpath per the repository style guide.

Suggested change
# Record metadata for duration tracking on teardown
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
with open(meta_path, "w") as f:
json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f)
# Record metadata for duration tracking on teardown
config_filename = os.getenv("SQLALCHEMY_SPANNER_CONFIG", "test.cfg")
config_base = os.path.basename(config_filename)
meta_path = os.path.normpath(os.path.join(os.path.dirname(__file__), f".db_session_info_{config_base}.json"))
with open(meta_path, "w") as f:
json.dump({"database_id": database_id, "creation_time": creation_timestamp}, f)
References
  1. Path Normalization: When comparing path strings (especially paths retrieved from environment variables or dynamically built), always normalize them using os.path.normpath or pathlib.Path to prevent Windows vs Unix slash mismatch issues. (link)

Comment on lines +84 to +100
# Calculate and report active duration with type-validation for compliance
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
if os.path.exists(meta_path):
try:
with open(meta_path, "r") as f:
meta = json.load(f)
if isinstance(meta, dict):
creation_time = meta.get("creation_time", time.time())
db_name = meta.get("database_id", database_id_str)
elapsed_seconds = time.time() - creation_time
duration_str = format_duration(elapsed_seconds)
print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.")
except Exception:
pass
finally:
if os.path.exists(meta_path):
os.remove(meta_path)

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.

high

To match the session-isolated metadata file name suggested in create_test_database.py, we should derive the metadata path from config_filename here as well, normalizing the path using os.path.normpath per the repository style guide.

Suggested change
# Calculate and report active duration with type-validation for compliance
meta_path = os.path.join(os.path.dirname(__file__), ".db_session_info.json")
if os.path.exists(meta_path):
try:
with open(meta_path, "r") as f:
meta = json.load(f)
if isinstance(meta, dict):
creation_time = meta.get("creation_time", time.time())
db_name = meta.get("database_id", database_id_str)
elapsed_seconds = time.time() - creation_time
duration_str = format_duration(elapsed_seconds)
print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.")
except Exception:
pass
finally:
if os.path.exists(meta_path):
os.remove(meta_path)
# Calculate and report active duration with type-validation for compliance
config_base = os.path.basename(config_filename)
meta_path = os.path.normpath(os.path.join(os.path.dirname(__file__), f".db_session_info_{config_base}.json"))
if os.path.exists(meta_path):
try:
with open(meta_path, "r") as f:
meta = json.load(f)
if isinstance(meta, dict):
creation_time = meta.get("creation_time", time.time())
db_name = meta.get("database_id", database_id_str)
elapsed_seconds = time.time() - creation_time
duration_str = format_duration(elapsed_seconds)
print(f"[Spanner DB] Database {db_name} was active for {duration_str} before teardown.")
except Exception:
pass
finally:
if os.path.exists(meta_path):
os.remove(meta_path)
References
  1. Path Normalization: When comparing path strings (especially paths retrieved from environment variables or dynamically built), always normalize them using os.path.normpath or pathlib.Path to prevent Windows vs Unix slash mismatch issues. (link)

Comment thread packages/sqlalchemy-spanner/noxfile.py Outdated
Comment on lines +184 to +202
config_file = os.environ.setdefault(
"SQLALCHEMY_SPANNER_CONFIG",
f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg",
)
session.run("python", "create_test_database.py", config_file)
config = configparser.ConfigParser()
config.read(config_file)
db_url = config.get("db", "default")
try:
yield config_file, db_url
finally:
if os.path.exists(config_file):
session.run(
"python", "drop_test_database.py", config_file, success_codes=[0, 1]
)
elif os.path.exists("test.cfg"):
session.run(
"python", "drop_test_database.py", "test.cfg", success_codes=[0, 1]
)

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

Using os.environ.setdefault to set SQLALCHEMY_SPANNER_CONFIG will persist the environment variable across different nox sessions running in the same parent process. This can break session isolation.

We should explicitly set and restore (or clean up) the environment variable within the context manager to ensure clean isolation.

    config_file = f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg"
    original_config = os.environ.get("SQLALCHEMY_SPANNER_CONFIG")
    os.environ["SQLALCHEMY_SPANNER_CONFIG"] = config_file
    session.run("python", "create_test_database.py", config_file)
    config = configparser.ConfigParser()
    config.read(config_file)
    db_url = config.get("db", "default")
    try:
        yield config_file, db_url
    finally:
        try:
            if os.path.exists(config_file):
                session.run(
                    "python", "drop_test_database.py", config_file, success_codes=[0, 1]
                )
            elif os.path.exists("test.cfg"):
                session.run(
                    "python", "drop_test_database.py", "test.cfg", success_codes=[0, 1]
                )
        finally:
            if original_config is not None:
                os.environ["SQLALCHEMY_SPANNER_CONFIG"] = original_config
            else:
                os.environ.pop("SQLALCHEMY_SPANNER_CONFIG", None)

Comment on lines +412 to +415
config_file = os.environ.setdefault(
"SQLALCHEMY_SPANNER_CONFIG",
f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg",
)

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

The config_file variable defined here is never used within the system function. Furthermore, setting SQLALCHEMY_SPANNER_CONFIG globally here via setdefault can pollute the environment for other test sessions. Since spanner_test_database handles its own configuration isolation, this assignment is redundant and should be removed.

@chalmerlowe
chalmerlowe force-pushed the refactor/sqlalchemy-spanner-clean-isolation branch 18 times, most recently from 3440690 to 632d2e4 Compare August 21, 2026 14:51
…ture relying on session-isolated ephemeral test database lifecycle
@chalmerlowe
chalmerlowe force-pushed the refactor/sqlalchemy-spanner-clean-isolation branch from 632d2e4 to ccf14b4 Compare August 21, 2026 14:55
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