1515# limitations under the License.
1616
1717import os
18+ import pathlib
1819import re
1920import time
21+ import uuid
2022
23+ from create_test_config import set_test_config
2124from google .api_core import datetime_helpers
2225from google .api_core .exceptions import AlreadyExists , ResourceExhausted
2326from google .cloud .spanner_v1 import Client
2427from google .cloud .spanner_v1 .database import Database
2528from google .cloud .spanner_v1 .instance import Instance
2629
27- from create_test_config import set_test_config
28-
2930USE_EMULATOR = os .getenv ("SPANNER_EMULATOR_HOST" ) is not None
3031
3132PROJECT = os .getenv (
@@ -55,8 +56,6 @@ def delete_stale_test_instances():
5556 create_time = int (instance .labels ["created" ])
5657 if create_time > cutoff :
5758 continue
58- # Backups are not used in sqlalchemy dialect test,
59- # therefore instance can just be deleted.
6059 try :
6160 instance .delete ()
6261 time .sleep (5 ) # Sleep for 5 seconds to give time for cooldown.
@@ -69,26 +68,39 @@ def delete_stale_test_instances():
6968
7069
7170def delete_stale_test_databases ():
72- """Delete test databases that are older than 10 minutes.
73-
74- In this test suite, active databases typically finish running in ~5 minutes.
75- To prevent concurrent Kokoro runs from accidentally deleting each other's
76- active databases we use a 10-minute safety threshold. Without an aggressive
77- cutoff we quickly bump up against Cloud Spanner's limit of 100 databases per instance.
71+ """Delete test databases that are older than 4 hours.
72+
73+ Uses a .stale_cleanup_done sentinel file gate to ensure this global sweep
74+ runs exactly once at the start of a test run across parallel/parametrized sessions,
75+ preventing concurrent sessions from interfering with each other.
7876 """
79- cutoff = (int (time .time ()) - 10 * 60 ) * 1000
77+ marker = pathlib .Path (".stale_cleanup_done" )
78+ if marker .exists ():
79+ return
80+
81+ try :
82+ marker .touch (exist_ok = False )
83+ except FileExistsError :
84+ return # Another parallel process already performed cleanup
85+
86+ cutoff = (int (time .time ()) - 4 * 60 * 60 ) * 1000
8087 instance = CLIENT .instance ("sqlalchemy-dialect-test" )
8188 if not instance .exists ():
8289 return
8390 database_pbs = instance .list_databases ()
8491 for database_pb in database_pbs :
8592 database = Database .from_pb (database_pb , instance )
86- # Parse creation time from database ID first (e.g. "sqlalchemy-test-1779989493809")
87- # to be 100% independent of emulator metadata or GCP Client API create_time gaps!
93+
94+ # Parse creation time from database ID first (e.g. "sp_test_1787069488_a3f2b1")
8895 create_time = None
89- match = re .match (r"sqlalchemy-test- (\d+)" , database .database_id )
96+ match = re .match (r"sp_test_ (\d+)" , database .database_id )
9097 if match :
91- create_time = int (match .group (1 ))
98+ ts_str = match .group (1 )
99+ ts_val = int (ts_str )
100+ if len (ts_str ) == 10 :
101+ create_time = ts_val * 1000
102+ else :
103+ create_time = ts_val
92104 elif database_pb .create_time is not None :
93105 create_time = datetime_helpers .to_milliseconds (database_pb .create_time )
94106
@@ -105,6 +117,12 @@ def delete_stale_test_databases():
105117
106118
107119def create_test_instance ():
120+ """Create test instance and a session-isolated ephemeral test database.
121+
122+ To prevent cross-session test pollution and parallel execution race conditions,
123+ each test session generates a unique database ID combining a 10-digit timestamp
124+ and a 6-character random hex suffix (conforming to Spanner's regex: ^[a-z][a-z0-9_]{1,29}$).
125+ """
108126 instance_id = "sqlalchemy-dialect-test"
109127 instance = CLIENT .instance (instance_id )
110128 if not instance .exists ():
@@ -123,8 +141,12 @@ def create_test_instance():
123141 except AlreadyExists :
124142 pass # instance was already created
125143
126- unique_resource_id = "%s%d" % ("-" , 1000 * time .time ())
127- database_id = "sqlalchemy-test" + unique_resource_id
144+ # Generate a session-isolated unique database ID within Spanner 30-char limit
145+ # Format: sp_test_{timestamp_in_seconds}_{rand_hex6} (compliant with Spanner naming: ^[a-z][a-z0-9_]{1,29}$)
146+ creation_timestamp = time .time ()
147+ timestamp_part = str (int (creation_timestamp ))
148+ rand_part = uuid .uuid4 ().hex [:6 ]
149+ database_id = f"sp_test_{ timestamp_part } _{ rand_part } "
128150
129151 try :
130152 database = instance .database (database_id )
@@ -136,5 +158,14 @@ def create_test_instance():
136158 set_test_config (PROJECT , instance_id , database_id )
137159
138160
139- delete_stale_test_databases ()
140- create_test_instance ()
161+ def main (argv ):
162+ config_filename = argv [0 ] if argv else os .getenv ("SQLALCHEMY_SPANNER_CONFIG" , "test.cfg" )
163+ os .environ ["SQLALCHEMY_SPANNER_CONFIG" ] = config_filename
164+
165+ delete_stale_test_databases ()
166+ create_test_instance ()
167+
168+
169+ if __name__ == "__main__" :
170+ import sys
171+ main (sys .argv [1 :])
0 commit comments