fix(sqlalchemy-spanner): robust session-isolated test databases and clean lifecycle management - #18179
fix(sqlalchemy-spanner): robust session-isolated test databases and clean lifecycle management#18179chalmerlowe wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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().
| 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
- Remove duplicate lines of code, especially duplicate assertions in tests, to keep the codebase clean and avoid redundancy.
| # 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) |
There was a problem hiding this comment.
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.
| # 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
- 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)
| # 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) |
There was a problem hiding this comment.
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.
| # 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
- 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)
| 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] | ||
| ) |
There was a problem hiding this comment.
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)| config_file = os.environ.setdefault( | ||
| "SQLALCHEMY_SPANNER_CONFIG", | ||
| f"test_{test_type}_{session.python}_{uuid.uuid4().hex[:6]}.cfg", | ||
| ) |
There was a problem hiding this comment.
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.
3440690 to
632d2e4
Compare
…ture relying on session-isolated ephemeral test database lifecycle
632d2e4 to
ccf14b4
Compare
Clean, uncluttered implementation isolating SQLAlchemy Spanner test databases, complying with Spanner naming rules, and DRYing up session lifecycles using a clean context manager.