Skip to content

Commit 94c09fd

Browse files
chore(bigtable): prevent test leaks (#17350)
The bigtable system tests were leaving left-over instances, which could cause future tests to fail until they were manually cleaned up This PR improves the test logic to make sure resources are properly cleaned up Also added a fixture to remove test instances created more than a day ago --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 7611ac7 commit 94c09fd

4 files changed

Lines changed: 128 additions & 32 deletions

File tree

packages/google-cloud-bigtable/tests/system/admin_overlay/test_system_async.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from typing import Tuple
1818

1919
import pytest
20-
from google.api_core import exceptions
2120
from google.cloud.environment_vars import BIGTABLE_EMULATOR
2221

2322
from google.cloud import bigtable_admin_v2 as admin_v2
@@ -89,33 +88,49 @@ async def instance_admin_client(admin_overlay_project_id):
8988

9089

9190
@CrossSync.convert
92-
@CrossSync.pytest_fixture(scope="session")
91+
@CrossSync.pytest_fixture(scope="session", autouse=True)
92+
async def cleanup_old_instances(admin_overlay_project_id):
93+
"""
94+
Automatically deletes any test instances older than 1 day.
95+
96+
This fixture runs once per test session and helps prevent resource leakage
97+
by cleaning up instances that failed to be deleted during previous test runs.
98+
"""
99+
from tests.system.utils import clear_stale_instances
100+
101+
from .conftest import INSTANCE_PREFIX
102+
103+
clear_stale_instances(admin_overlay_project_id, INSTANCE_PREFIX, older_than_days=1)
104+
105+
106+
@CrossSync.convert
107+
@CrossSync.pytest_fixture(scope="function")
93108
async def instances_to_delete(instance_admin_client):
94109
instances = []
95110

96111
try:
97112
yield instances
98113
finally:
99-
for instance in instances:
114+
for instance in reversed(instances):
100115
try:
101116
await instance_admin_client.delete_instance(name=instance.name)
102-
except exceptions.NotFound:
103-
pass
117+
except Exception as e:
118+
print(f"Failed to delete instance {instance.name}: {e}")
104119

105120

106121
@CrossSync.convert
107-
@CrossSync.pytest_fixture(scope="session")
122+
@CrossSync.pytest_fixture(scope="function")
108123
async def backups_to_delete(table_admin_client):
109124
backups = []
110125

111126
try:
112127
yield backups
113128
finally:
114-
for backup in backups:
129+
for backup in reversed(backups):
115130
try:
116131
await table_admin_client.delete_backup(name=backup.name)
117-
except exceptions.NotFound:
118-
pass
132+
except Exception as e:
133+
print(f"Failed to delete backup {backup.name}: {e}")
119134

120135

121136
@CrossSync.convert
@@ -169,7 +184,8 @@ async def create_instance(
169184

170185
# add to cleanup list before waiting for result, in case of timeout
171186
instance_name = instance_admin_client.instance_path(project_id, instance_id)
172-
instances_to_delete.append(admin_v2.Instance(name=instance_name))
187+
instance_placeholder = admin_v2.Instance(name=instance_name)
188+
instances_to_delete.append(instance_placeholder)
173189

174190
instance = await operation.result()
175191

@@ -260,9 +276,9 @@ async def create_backup(
260276
)
261277

262278
# add to cleanup list before waiting for result, in case of timeout
263-
backups_to_delete.append(
264-
admin_v2.Backup(name=f"{cluster_name}/backups/{backup_id}")
265-
)
279+
backup_name = f"{cluster_name}/backups/{backup_id}"
280+
backup_placeholder = admin_v2.Backup(name=backup_name)
281+
backups_to_delete.append(backup_placeholder)
266282

267283
backup = await operation.result()
268284

packages/google-cloud-bigtable/tests/system/admin_overlay/test_system_autogen.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from typing import Tuple
2121

2222
import pytest
23-
from google.api_core import exceptions
2423
from google.api_core import operation as api_core_operation
2524
from google.cloud.environment_vars import BIGTABLE_EMULATOR
2625

@@ -73,30 +72,43 @@ def instance_admin_client(admin_overlay_project_id):
7372
yield client
7473

7574

76-
@pytest.fixture(scope="session")
75+
@pytest.fixture(scope="session", autouse=True)
76+
def cleanup_old_instances(admin_overlay_project_id):
77+
"""Automatically deletes any test instances older than 1 day.
78+
79+
This fixture runs once per test session and helps prevent resource leakage
80+
by cleaning up instances that failed to be deleted during previous test runs."""
81+
from tests.system.utils import clear_stale_instances
82+
83+
from .conftest import INSTANCE_PREFIX
84+
85+
clear_stale_instances(admin_overlay_project_id, INSTANCE_PREFIX, older_than_days=1)
86+
87+
88+
@pytest.fixture(scope="function")
7789
def instances_to_delete(instance_admin_client):
7890
instances = []
7991
try:
8092
yield instances
8193
finally:
82-
for instance in instances:
94+
for instance in reversed(instances):
8395
try:
8496
instance_admin_client.delete_instance(name=instance.name)
85-
except exceptions.NotFound:
86-
pass
97+
except Exception as e:
98+
print(f"Failed to delete instance {instance.name}: {e}")
8799

88100

89-
@pytest.fixture(scope="session")
101+
@pytest.fixture(scope="function")
90102
def backups_to_delete(table_admin_client):
91103
backups = []
92104
try:
93105
yield backups
94106
finally:
95-
for backup in backups:
107+
for backup in reversed(backups):
96108
try:
97109
table_admin_client.delete_backup(name=backup.name)
98-
except exceptions.NotFound:
99-
pass
110+
except Exception as e:
111+
print(f"Failed to delete backup {backup.name}: {e}")
100112

101113

102114
def create_instance(
@@ -135,7 +147,8 @@ def create_instance(
135147
)
136148
operation = instance_admin_client.create_instance(create_instance_request)
137149
instance_name = instance_admin_client.instance_path(project_id, instance_id)
138-
instances_to_delete.append(admin_v2.Instance(name=instance_name))
150+
instance_placeholder = admin_v2.Instance(name=instance_name)
151+
instances_to_delete.append(instance_placeholder)
139152
instance = operation.result()
140153
instances_to_delete[-1] = instance
141154
create_table_request = admin_v2.CreateTableRequest(
@@ -198,9 +211,9 @@ def create_backup(
198211
),
199212
)
200213
)
201-
backups_to_delete.append(
202-
admin_v2.Backup(name=f"{cluster_name}/backups/{backup_id}")
203-
)
214+
backup_name = f"{cluster_name}/backups/{backup_id}"
215+
backup_placeholder = admin_v2.Backup(name=backup_name)
216+
backups_to_delete.append(backup_placeholder)
204217
backup = operation.result()
205218
backups_to_delete[-1] = backup
206219
return backup

packages/google-cloud-bigtable/tests/system/data/__init__.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ class SystemTestRunner:
3434
used by standard system tests, and metrics tests
3535
"""
3636

37+
@pytest.fixture(scope="session", autouse=True)
38+
def cleanup_old_instances(self, project_id):
39+
"""
40+
Automatically deletes any test instances older than 1 day.
41+
"""
42+
from tests.system.utils import clear_stale_instances
43+
44+
clear_stale_instances(project_id, "python-bigtable-tests", older_than_days=1)
45+
3746
@pytest.fixture(scope="session")
3847
def init_table_id(self):
3948
"""
@@ -128,8 +137,8 @@ def instance_id(self, admin_client, project_id, cluster_config):
128137
admin_client.instance_admin_client.delete_instance(
129138
name=f"projects/{project_id}/instances/{instance_id}"
130139
)
131-
except exceptions.NotFound:
132-
pass
140+
except Exception as e:
141+
print(f"Failed to delete instance {instance_id}: {e}")
133142

134143
@pytest.fixture(scope="session")
135144
def column_split_config(self):
@@ -195,8 +204,8 @@ def table_id(
195204
admin_client.table_admin_client.delete_table(
196205
name=f"{parent_path}/tables/{init_table_id}"
197206
)
198-
except exceptions.NotFound:
199-
print(f"Table {init_table_id} not found, skipping deletion")
207+
except Exception as e:
208+
print(f"Failed to delete table {init_table_id}: {e}")
200209

201210
@pytest.fixture(scope="session")
202211
def authorized_view_id(
@@ -256,8 +265,8 @@ def authorized_view_id(
256265
admin_client.table_admin_client.delete_authorized_view(
257266
name=new_path
258267
)
259-
except exceptions.NotFound:
260-
print(f"View {new_view_id} not found, skipping deletion")
268+
except Exception as e:
269+
print(f"Failed to delete view {new_view_id}: {e}")
261270

262271
@pytest.fixture(scope="session")
263272
def project_id(self, client):
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from datetime import datetime, timedelta, timezone
16+
17+
from google.api_core.exceptions import NotFound
18+
19+
from google.cloud import bigtable_admin_v2 as admin_v2
20+
21+
22+
def clear_stale_instances(project_id: str, prefix: str, older_than_days: int = 1):
23+
"""
24+
Synchronously deletes any instances in the given project that are older
25+
than older_than_days and whose name or display name matches the given prefix.
26+
"""
27+
client = admin_v2.BigtableInstanceAdminClient(
28+
client_options={"quota_project_id": project_id}
29+
)
30+
parent = client.common_project_path(project_id)
31+
next_page_token = ""
32+
33+
while True:
34+
try:
35+
response = client.list_instances(
36+
request={"parent": parent, "page_token": next_page_token}
37+
)
38+
except Exception:
39+
# Cannot list instances, skip cleanup
40+
break
41+
42+
for instance in response.instances:
43+
# Check if instance matches the prefix
44+
display_name_matches = instance.display_name.startswith(prefix)
45+
name_matches = instance.name.split("/")[-1].startswith(prefix)
46+
47+
if display_name_matches or name_matches:
48+
if instance.create_time:
49+
now = datetime.now(timezone.utc)
50+
if now - instance.create_time > timedelta(days=older_than_days):
51+
try:
52+
client.delete_instance(name=instance.name)
53+
except NotFound:
54+
pass
55+
56+
next_page_token = response.next_page_token
57+
if not next_page_token:
58+
break

0 commit comments

Comments
 (0)