Skip to content

Commit da435da

Browse files
committed
fix(spanner): stop deleting multiplexed sessions on rotation and close
Multiplexed sessions in Cloud Spanner cannot and should not be deleted by client applications. Calling DeleteSession on a multiplexed session causes Cloud Spanner to return an INVALID_ARGUMENT error. In addition, calling delete() upon session rotation risks aborting in-flight queries that are still actively using the previous session reference. - Remove `old_session.delete()` from `_rotate_multiplexed_session()` in both `_async/database_sessions_manager.py` and `database_sessions_manager.py`. - Remove `session_to_delete.delete()` from `close()` in both managers, simply clearing the session reference (`self._multiplexed_session = None`) and relying on Spanner's server-side session eviction once idle. - Update docstrings to reflect that multiplexed sessions are rotated rather than deleted and recreated.
1 parent 9569b39 commit da435da

5 files changed

Lines changed: 39 additions & 168 deletions

File tree

packages/google-cloud-spanner/google/cloud/spanner_v1/_async/database_sessions_manager.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ async def _get_multiplexed_session(self) -> Session:
123123
"""Returns a multiplexed session from the database session manager.
124124
125125
If the multiplexed session is not defined, creates a new multiplexed
126-
session and starts a maintenance thread to periodically delete and
127-
recreate it so that it remains valid. Otherwise, simply returns the
126+
session and starts a maintenance thread to periodically rotate
127+
it so that it remains valid. Otherwise, simply returns the
128128
current multiplexed session.
129129
130130
:rtype: :class:`~google.cloud.spanner_v1.session.Session`
@@ -167,8 +167,8 @@ def _build_maintenance_thread(
167167
self, session: Optional[Session] = None
168168
) -> CrossSync.Task:
169169
"""Builds and returns a multiplexed session maintenance thread for
170-
the database session manager. This thread will periodically delete
171-
and recreate the multiplexed session to ensure that it is always valid.
170+
the database session manager. This thread will periodically rotate
171+
the multiplexed session to ensure that it is always valid.
172172
173173
:type session: :class:`~google.cloud.spanner_v1.session.Session`
174174
:param session: (Optional) The multiplexed session to maintain.
@@ -209,25 +209,18 @@ async def _rotate_multiplexed_session(self) -> bool:
209209
return False
210210

211211
async with self._multiplexed_session_lock:
212-
old_session = self._multiplexed_session
213212
self._multiplexed_session = new_session
214213

215-
if old_session is not None:
216-
try:
217-
await CrossSync.run_if_async(old_session.delete)
218-
except Exception:
219-
pass
220-
221214
return True
222215

223216
@staticmethod
224217
@CrossSync.convert
225218
async def _maintain_multiplexed_session(session_manager_ref) -> None:
226219
"""Maintains the multiplexed session for the database session manager.
227220
228-
This method will delete and recreate the referenced database session manager's
221+
This method will periodically rotate the referenced database session manager's
229222
multiplexed session to ensure that it is always valid. The method will run until
230-
the database session manager is deleted or the multiplexed session is deleted.
223+
the database session manager is garbage collected or the session manager is closed.
231224
232225
:type session_manager_ref: :class:`_weakref.ReferenceType`
233226
:param session_manager_ref: A weak reference to the database session manager."""
@@ -292,7 +285,4 @@ async def close(self) -> None:
292285
pass
293286
else:
294287
self._multiplexed_session_thread.join()
295-
if self._multiplexed_session is not None:
296-
session_to_delete = self._multiplexed_session
297-
self._multiplexed_session = None
298-
await session_to_delete.delete()
288+
self._multiplexed_session = None

packages/google-cloud-spanner/google/cloud/spanner_v1/database_sessions_manager.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ def _get_multiplexed_session(self) -> Session:
121121
"""Returns a multiplexed session from the database session manager.
122122
123123
If the multiplexed session is not defined, creates a new multiplexed
124-
session and starts a maintenance thread to periodically delete and
125-
recreate it so that it remains valid. Otherwise, simply returns the
124+
session and starts a maintenance thread to periodically rotate
125+
it so that it remains valid. Otherwise, simply returns the
126126
current multiplexed session.
127127
128128
:rtype: :class:`~google.cloud.spanner_v1.session.Session`
@@ -162,8 +162,8 @@ def _build_maintenance_thread(
162162
self, session: Optional[Session] = None
163163
) -> CrossSync._Sync_Impl.Task:
164164
"""Builds and returns a multiplexed session maintenance thread for
165-
the database session manager. This thread will periodically delete
166-
and recreate the multiplexed session to ensure that it is always valid.
165+
the database session manager. This thread will periodically rotate
166+
the multiplexed session to ensure that it is always valid.
167167
168168
:type session: :class:`~google.cloud.spanner_v1.session.Session`
169169
:param session: (Optional) The multiplexed session to maintain.
@@ -196,24 +196,17 @@ def _rotate_multiplexed_session(self) -> bool:
196196
return False
197197

198198
with self._multiplexed_session_lock:
199-
old_session = self._multiplexed_session
200199
self._multiplexed_session = new_session
201200

202-
if old_session is not None:
203-
try:
204-
CrossSync._Sync_Impl.run_if_async(old_session.delete)
205-
except Exception:
206-
pass
207-
208201
return True
209202

210203
@staticmethod
211204
def _maintain_multiplexed_session(session_manager_ref) -> None:
212205
"""Maintains the multiplexed session for the database session manager.
213206
214-
This method will delete and recreate the referenced database session manager's
207+
This method will periodically rotate the referenced database session manager's
215208
multiplexed session to ensure that it is always valid. The method will run until
216-
the database session manager is deleted or the multiplexed session is deleted.
209+
the database session manager is garbage collected or the session manager is closed.
217210
218211
:type session_manager_ref: :class:`_weakref.ReferenceType`
219212
:param session_manager_ref: A weak reference to the database session manager."""
@@ -269,7 +262,4 @@ def close(self) -> None:
269262
self._multiplexed_session_terminate_event.set()
270263
if self._multiplexed_session_thread is not None:
271264
self._multiplexed_session_thread.join()
272-
if self._multiplexed_session is not None:
273-
session_to_delete = self._multiplexed_session
274-
self._multiplexed_session = None
275-
session_to_delete.delete()
265+
self._multiplexed_session = None

packages/google-cloud-spanner/tests/unit/_async/test_database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def __await__(self):
189189

190190
manager._multiplexed_session_terminate_event.set.assert_called_once()
191191
manager._multiplexed_session_thread.cancel.assert_called_once()
192-
mock_session.delete.assert_called_once()
192+
mock_session.delete.assert_not_called()
193193
self.assertIsNone(manager._multiplexed_session)
194194

195195
@CrossSync.pytest

packages/google-cloud-spanner/tests/unit/_async/test_sessions_manager_extra.py

Lines changed: 18 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ async def fake_coro():
105105

106106
task = asyncio.create_task(fake_coro())
107107
manager._multiplexed_session_thread = task
108-
manager._multiplexed_session = mock.AsyncMock()
108+
mock_session = mock.AsyncMock()
109+
manager._multiplexed_session = mock_session
109110
manager._multiplexed_session_terminate_event = mock.Mock()
110111

111112
with mock.patch(
@@ -116,10 +117,13 @@ async def fake_coro():
116117
# task is cancelled and awaited in close()
117118
self.assertTrue(task.done())
118119
manager._multiplexed_session_terminate_event.set.assert_called_once()
120+
self.assertIsNone(manager._multiplexed_session)
121+
mock_session.delete.assert_not_called()
119122

120123
# Sync branch of close
121124
manager._multiplexed_session_thread = mock.Mock()
122-
manager._multiplexed_session = mock.AsyncMock()
125+
mock_session_sync = mock.AsyncMock()
126+
manager._multiplexed_session = mock_session_sync
123127
manager._multiplexed_session_terminate_event = mock.Mock()
124128
with mock.patch(
125129
"google.cloud.spanner_v1._async.database_sessions_manager.CrossSync.is_async",
@@ -128,6 +132,8 @@ async def fake_coro():
128132
await manager.close()
129133
self.assertTrue(manager._multiplexed_session_thread.join.called)
130134
manager._multiplexed_session_terminate_event.set.assert_called_once()
135+
self.assertIsNone(manager._multiplexed_session)
136+
mock_session_sync.delete.assert_not_called()
131137

132138
async def test_maintain_multiplexed_session_refresh(self):
133139
# coverage for line 196-202
@@ -250,25 +256,21 @@ async def test_get_multiplexed_session_fast_path_lock_already_created(self):
250256
mock_lock.acquire.assert_not_called()
251257
mock_lock.__aenter__.assert_not_called()
252258

253-
async def test_maintain_multiplexed_session_swaps_before_deleting_old_session(
254-
self,
255-
):
259+
async def test_maintain_multiplexed_session_rotates_session(self):
256260
from weakref import ref
257261

258262
manager = DatabaseSessionsManager(self.database, self.pool)
259263
manager._multiplexed_session_lock = asyncio.Lock()
260-
manager._multiplexed_session_terminate_event = asyncio.Event()
264+
manager._multiplexed_session_terminate_event = mock.Mock()
265+
manager._multiplexed_session_terminate_event.is_set.side_effect = [
266+
False,
267+
True,
268+
]
261269

262270
old_session = mock.AsyncMock()
263271
new_session = mock.AsyncMock()
264272
manager._multiplexed_session = old_session
265273

266-
async def verify_swap_on_delete():
267-
self.assertIs(manager._multiplexed_session, new_session)
268-
manager._multiplexed_session_terminate_event.set()
269-
270-
old_session.delete.side_effect = verify_swap_on_delete
271-
272274
refresh_interval = manager._MAINTENANCE_THREAD_REFRESH_INTERVAL.total_seconds()
273275
call_count = 0
274276

@@ -290,7 +292,8 @@ def mock_time():
290292
ref(manager)
291293
)
292294
mock_build.assert_called_once()
293-
old_session.delete.assert_called_once()
295+
old_session.delete.assert_not_called()
296+
new_session.delete.assert_not_called()
294297
self.assertIs(manager._multiplexed_session, new_session)
295298

296299
async def test_maintain_multiplexed_session_handles_build_failure(self):
@@ -337,48 +340,6 @@ async def mock_event_wait(event, timeout=None):
337340
current_session.delete.assert_not_called()
338341
self.assertIs(manager._multiplexed_session, current_session)
339342

340-
async def test_maintain_multiplexed_session_handles_delete_failure(self):
341-
from weakref import ref
342-
343-
manager = DatabaseSessionsManager(self.database, self.pool)
344-
manager._multiplexed_session_lock = asyncio.Lock()
345-
manager._multiplexed_session_terminate_event = asyncio.Event()
346-
347-
old_session = mock.AsyncMock()
348-
old_session.delete.side_effect = Exception("delete failed")
349-
new_session = mock.AsyncMock()
350-
manager._multiplexed_session = old_session
351-
352-
async def verify_swap_on_delete():
353-
self.assertIs(manager._multiplexed_session, new_session)
354-
manager._multiplexed_session_terminate_event.set()
355-
raise Exception("delete failed")
356-
357-
old_session.delete.side_effect = verify_swap_on_delete
358-
359-
refresh_interval = manager._MAINTENANCE_THREAD_REFRESH_INTERVAL.total_seconds()
360-
call_count = 0
361-
362-
def mock_time():
363-
nonlocal call_count
364-
call_count += 1
365-
if call_count == 1:
366-
return 0
367-
return refresh_interval + 100
368-
369-
with mock.patch(
370-
"google.cloud.spanner_v1._async.database_sessions_manager.time.monotonic",
371-
side_effect=mock_time,
372-
):
373-
with mock.patch.object(
374-
manager, "_build_multiplexed_session", return_value=new_session
375-
):
376-
await DatabaseSessionsManager._maintain_multiplexed_session(
377-
ref(manager)
378-
)
379-
old_session.delete.assert_called_once()
380-
self.assertIs(manager._multiplexed_session, new_session)
381-
382343
async def test_maintain_multiplexed_session_old_session_none(self):
383344
from weakref import ref
384345

@@ -580,7 +541,8 @@ async def test_rotate_multiplexed_session_success(self):
580541
result = await manager._rotate_multiplexed_session()
581542
self.assertTrue(result)
582543
self.assertIs(manager._multiplexed_session, new_session)
583-
old_session.delete.assert_called_once()
544+
old_session.delete.assert_not_called()
545+
new_session.delete.assert_not_called()
584546

585547
async def test_rotate_multiplexed_session_build_failure(self):
586548
manager = DatabaseSessionsManager(self.database, self.pool)
@@ -597,19 +559,3 @@ async def test_rotate_multiplexed_session_build_failure(self):
597559
self.assertFalse(result)
598560
self.assertIs(manager._multiplexed_session, current_session)
599561
current_session.delete.assert_not_called()
600-
601-
async def test_rotate_multiplexed_session_delete_failure(self):
602-
manager = DatabaseSessionsManager(self.database, self.pool)
603-
manager._multiplexed_session_lock = asyncio.Lock()
604-
old_session = mock.AsyncMock()
605-
old_session.delete.side_effect = Exception("delete failed")
606-
new_session = mock.AsyncMock()
607-
manager._multiplexed_session = old_session
608-
609-
with mock.patch.object(
610-
manager, "_build_multiplexed_session", return_value=new_session
611-
):
612-
result = await manager._rotate_multiplexed_session()
613-
self.assertTrue(result)
614-
self.assertIs(manager._multiplexed_session, new_session)
615-
old_session.delete.assert_called_once()

packages/google-cloud-spanner/tests/unit/test_database_session_manager.py

Lines changed: 6 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def test_get_multiplexed_session_fast_path_lock_already_created(self):
274274
mock_lock.acquire.assert_not_called()
275275
mock_lock.__enter__.assert_not_called()
276276

277-
def test_maintain_multiplexed_session_swaps_before_deleting_old_session(self):
277+
def test_maintain_multiplexed_session_rotates_session(self):
278278
import threading
279279
from weakref import ref
280280

@@ -287,11 +287,6 @@ def test_maintain_multiplexed_session_swaps_before_deleting_old_session(self):
287287
new_session = Mock()
288288
manager._multiplexed_session = old_session
289289

290-
def verify_swap_on_delete():
291-
self.assertIs(manager._multiplexed_session, new_session)
292-
293-
old_session.delete.side_effect = verify_swap_on_delete
294-
295290
call_count = 0
296291

297292
def mock_time():
@@ -310,7 +305,8 @@ def mock_time():
310305
) as mock_build:
311306
DatabaseSessionsManager._maintain_multiplexed_session(ref(manager))
312307
mock_build.assert_called_once()
313-
old_session.delete.assert_called_once()
308+
old_session.delete.assert_not_called()
309+
new_session.delete.assert_not_called()
314310
self.assertIs(manager._multiplexed_session, new_session)
315311

316312
def test_close_branches(self):
@@ -330,7 +326,7 @@ def test_close_branches(self):
330326
manager._multiplexed_session_terminate_event.set.assert_called_once()
331327
mock_thread.join.assert_called_once()
332328
self.assertIsNone(manager._multiplexed_session)
333-
mock_session.delete.assert_called_once()
329+
mock_session.delete.assert_not_called()
334330

335331
def test_maintain_multiplexed_session_handles_build_failure(self):
336332
import threading
@@ -374,40 +370,6 @@ def mock_time():
374370
current_session.delete.assert_not_called()
375371
self.assertIs(manager._multiplexed_session, current_session)
376372

377-
def test_maintain_multiplexed_session_handles_delete_failure(self):
378-
import threading
379-
from weakref import ref
380-
381-
manager = DatabaseSessionsManager(self._manager._database, self._manager._pool)
382-
manager._multiplexed_session_lock = threading.Lock()
383-
manager._multiplexed_session_terminate_event = Mock()
384-
manager._multiplexed_session_terminate_event.is_set.side_effect = [False, True]
385-
386-
old_session = Mock()
387-
old_session.delete.side_effect = Exception("delete failed")
388-
new_session = Mock()
389-
manager._multiplexed_session = old_session
390-
391-
call_count = 0
392-
393-
def mock_time():
394-
nonlocal call_count
395-
call_count += 1
396-
if call_count == 1:
397-
return 0
398-
return 1000000
399-
400-
with patch(
401-
"google.cloud.spanner_v1.database_sessions_manager.time.monotonic",
402-
side_effect=mock_time,
403-
):
404-
with patch.object(
405-
manager, "_build_multiplexed_session", return_value=new_session
406-
):
407-
DatabaseSessionsManager._maintain_multiplexed_session(ref(manager))
408-
old_session.delete.assert_called_once()
409-
self.assertIs(manager._multiplexed_session, new_session)
410-
411373
def test_maintain_multiplexed_session_old_session_none(self):
412374
import threading
413375
from weakref import ref
@@ -662,7 +624,8 @@ def test_rotate_multiplexed_session_success(self):
662624
result = manager._rotate_multiplexed_session()
663625
self.assertTrue(result)
664626
self.assertIs(manager._multiplexed_session, new_session)
665-
old_session.delete.assert_called_once()
627+
old_session.delete.assert_not_called()
628+
new_session.delete.assert_not_called()
666629

667630
def test_rotate_multiplexed_session_build_failure(self):
668631
import threading
@@ -682,24 +645,6 @@ def test_rotate_multiplexed_session_build_failure(self):
682645
self.assertIs(manager._multiplexed_session, current_session)
683646
current_session.delete.assert_not_called()
684647

685-
def test_rotate_multiplexed_session_delete_failure(self):
686-
import threading
687-
688-
manager = DatabaseSessionsManager(self._manager._database, self._manager._pool)
689-
manager._multiplexed_session_lock = threading.Lock()
690-
old_session = Mock()
691-
old_session.delete.side_effect = Exception("delete failed")
692-
new_session = Mock()
693-
manager._multiplexed_session = old_session
694-
695-
with patch.object(
696-
manager, "_build_multiplexed_session", return_value=new_session
697-
):
698-
result = manager._rotate_multiplexed_session()
699-
self.assertTrue(result)
700-
self.assertIs(manager._multiplexed_session, new_session)
701-
old_session.delete.assert_called_once()
702-
703648
def _assert_true_with_timeout(self, condition: Callable) -> None:
704649
"""Asserts that the given condition is met within a timeout period.
705650

0 commit comments

Comments
 (0)