Repository navigation
test(django-google-spanner): enable system tests and share a single per-run database #18990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,30 +4,29 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| # license that can be found in the LICENSE file or at | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # https://developers.google.com/open-source/licenses/bsd | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import atexit | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django.core.management import call_command | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django.db import connection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django.conf import settings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django.db import connections | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from google.api_core import exceptions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from google.cloud.spanner_v1 import Client | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from google.cloud.spanner_v1.instance import Backup, Instance | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from test_utils.retry import RetryErrors | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from google.cloud.spanner_v1.database import Database | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django_spanner import USE_EMULATOR | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django_spanner.creation import DatabaseCreation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CREATE_INSTANCE = os.getenv("GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE") is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SPANNER_OPERATION_TIMEOUT_IN_SECONDS = int( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.getenv("SPANNER_OPERATION_TIMEOUT_IN_SECONDS", 60) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.getenv("SPANNER_OPERATION_TIMEOUT_IN_SECONDS", 240) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EXISTING_INSTANCES = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| INSTANCE_ID = os.environ.get( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE", "spanner-django-python-systest" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| settings.DATABASES["default"]["INSTANCE"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PROJECT_ID = os.getenv( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "GOOGLE_CLOUD_PROJECT", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| settings.DATABASES["default"]["PROJECT"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "emulator-test-project") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DATABASE_NAME = os.getenv("DJANGO_SPANNER_DB", "django_test_db") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class Config(object): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -38,106 +37,86 @@ class Config(object): | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CLIENT = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| INSTANCE_CONFIG = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| INSTANCE = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DATABASE = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _list_instances(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return list(Config.CLIENT.list_instances()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _cleanup_stale_databases(instance): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Drop stale dj_sys_<timestamp>_* databases older than 1 hour.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cutoff = int(time.time()) - 1 * 60 * 60 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for db_pb in instance.list_databases(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| db = Database.from_pb(db_pb, instance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts = db.database_id.split("_") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(parts) >= 3 and parts[0] == "dj" and parts[1] == "sys": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_ts = int(parts[2]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except ValueError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if created_ts < cutoff: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| db.drop() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def setup_instance(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if Config.INSTANCE is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if USE_EMULATOR: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from google.auth.credentials import AnonymousCredentials | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.CLIENT = Client(project=PROJECT_ID, credentials=AnonymousCredentials()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.CLIENT = Client() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retry = RetryErrors(exceptions.ServiceUnavailable) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| configs = list(retry(Config.CLIENT.list_instance_configs)()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instances = retry(_list_instances)() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EXISTING_INSTANCES[:] = instances | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Delete test instances that are older than an hour. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cutoff = int(time.time()) - 1 * 60 * 60 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instance_pbs = Config.CLIENT.list_instances("labels.python-spanner-systests:true") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for instance_pb in instance_pbs: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instance = Instance.from_pb(instance_pb, Config.CLIENT) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "created" not in instance.labels: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| create_time = int(instance.labels["created"]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if create_time > cutoff: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not USE_EMULATOR: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Instance cannot be deleted while backups exist. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for backup_pb in instance.list_backups(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| backup = Backup.from_pb(backup_pb, instance) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| backup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instance.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if CREATE_INSTANCE: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not USE_EMULATOR: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Defend against back-end returning configs for regions we aren't | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # actually allowed to use. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| configs = [config for config in configs if "-us-" in config.name] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| configs = list(Config.CLIENT.list_instance_configs()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not configs: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError("List instance configs failed in module set up.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE_CONFIG = configs[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config_name = configs[0].name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| create_time = str(int(time.time())) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| labels = {"django-spanner-systests": "true", "created": create_time} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE = Config.CLIENT.instance( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| INSTANCE_ID, config_name, labels=labels | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE = Config.CLIENT.instance(INSTANCE_ID, configs[0].name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not Config.INSTANCE.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_op = Config.INSTANCE.create() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_op.result( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SPANNER_OPERATION_TIMEOUT_IN_SECONDS | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) # block until completion | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_op.result(SPANNER_OPERATION_TIMEOUT_IN_SECONDS) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE.reload() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.CLIENT = Client(project=PROJECT_ID) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE = Config.CLIENT.instance(INSTANCE_ID) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE.reload() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _cleanup_stale_databases(Config.INSTANCE) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def teardown_instance(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if CREATE_INSTANCE: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.INSTANCE.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def setup_database(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not Config.DATABASE.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| creation = DatabaseCreation(connection) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| creation._create_test_db(verbosity=0, autoclobber=False, keepdb=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if Config.DATABASE is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Running migrations on the db. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| call_command("migrate", interactive=False) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| active_db_name = connections["default"].settings_dict["NAME"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE = Config.INSTANCE.database(active_db_name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not Config.DATABASE.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_op = Config.DATABASE.create() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| created_op.result(SPANNER_OPERATION_TIMEOUT_IN_SECONDS) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except exceptions.AlreadyExists: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+94
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When running tests via Django's test runner (e.g., However, As a result:
To fix this, retrieve the active database name dynamically from
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def teardown_database(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from django.db import connections | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connections.close_all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if Config.DATABASE.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE.drop() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Clear the global client cache to avoid Stale Session errors | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # when the next test class creates a new database. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import django_spanner.base | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _cleanup_test_resources(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connections.close_all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if Config.DATABASE is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if Config.DATABASE.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE.drop() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Config.DATABASE = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| django_spanner.base._SPANNER_CLIENT_CACHE = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except ImportError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| atexit.register(_cleanup_test_resources) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generating a completely random database name (
dj_sys_{int(time.time())}_{uuid.uuid4().hex[:6]}) per test run can lead to orphaned databases accumulating in the Spanner instance if a test run crashes or is terminated abruptly (e.g., via SIGKILL, CI timeout, or runner cancellation), asatexithandlers do not run in those cases.Since Spanner has a limit on the number of databases per instance (typically 100), these orphaned databases will eventually cause all subsequent test runs to fail with quota/limit errors.
Consider one of the following improvements:
setup_instance()that lists existing databases in the Spanner instance and deletes any databases with thedj_sys_prefix that are older than a certain threshold (e.g., 2 hours).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
_cleanup_stale_databases()insetup_instance()to drop any staledj_sys_<timestamp>_*databases older than 1 hour.