Skip to content

Commit dd24029

Browse files
authored
fix(spanner): release transaction lock if inline begin fails (#18409)
When execute_update() or batch_update() starts an inline begin (transaction_id is None), it acquires self._lock while waiting for the transaction ID from the server. If that request failed with an error, the lock was never released. This was a latent bug in _async/transaction.py that became an active deadlock when async/sync parity was recently restored in snapshot.py: reintroducing _wait_for_transaction_begin() meant subsequent queries in the same transaction now try to acquire self._lock and hang. Wrap the request execution in try...finally in _async/transaction.py and regenerate sync transaction.py so self._lock is always released. Also: - Add unit tests for failed inline begin in both async and sync clients. - Add a GitHub Actions workflow to run Spanner mock server tests in CI. - Include the mockserver session in default local Nox runs.
1 parent a511b55 commit dd24029

5 files changed

Lines changed: 285 additions & 146 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
permissions:
2+
contents: read
3+
4+
concurrency:
5+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
6+
cancel-in-progress: true
7+
8+
on:
9+
pull_request:
10+
paths:
11+
- 'packages/google-cloud-spanner/**'
12+
- '.github/workflows/spanner-mockserver-tests.yml'
13+
push:
14+
branches:
15+
- main
16+
paths:
17+
- 'packages/google-cloud-spanner/**'
18+
- '.github/workflows/spanner-mockserver-tests.yml'
19+
20+
defaults:
21+
run:
22+
working-directory: packages/google-cloud-spanner
23+
24+
name: Run Spanner mockserver tests
25+
jobs:
26+
check_changes:
27+
runs-on: ubuntu-latest
28+
outputs:
29+
run_spanner: ${{ steps.filter.outputs.spanner }}
30+
steps:
31+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
32+
with:
33+
persist-credentials: false
34+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
35+
id: filter
36+
with:
37+
filters: |
38+
spanner:
39+
- 'packages/google-cloud-spanner/**'
40+
- '.github/workflows/spanner-mockserver-tests.yml'
41+
42+
mockserver-tests:
43+
needs: check_changes
44+
if: ${{ needs.check_changes.outputs.run_spanner == 'true' }}
45+
runs-on: ubuntu-latest
46+
47+
steps:
48+
- name: Checkout code
49+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
50+
with:
51+
persist-credentials: false
52+
- name: Set up Python 3.12
53+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
54+
with:
55+
python-version: "3.12"
56+
- name: Install nox
57+
run: python -m pip install nox
58+
- name: Run nox
59+
run: nox -s mockserver

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/transaction.py‎

Lines changed: 76 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -543,49 +543,50 @@ async def execute_update(
543543
is_inline_begin = True
544544
await self._lock.acquire()
545545

546-
execute_sql_request = ExecuteSqlRequest(
547-
session=session.name,
548-
transaction=self._build_transaction_selector_pb(),
549-
sql=dml,
550-
params=params_pb,
551-
param_types=param_types,
552-
query_mode=query_mode,
553-
query_options=query_options,
554-
seqno=seqno,
555-
request_options=request_options,
556-
last_statement=last_statement,
557-
)
546+
try:
547+
execute_sql_request = ExecuteSqlRequest(
548+
session=session.name,
549+
transaction=self._build_transaction_selector_pb(),
550+
sql=dml,
551+
params=params_pb,
552+
param_types=param_types,
553+
query_mode=query_mode,
554+
query_options=query_options,
555+
seqno=seqno,
556+
request_options=request_options,
557+
last_statement=last_statement,
558+
)
558559

559-
nth_request = database._next_nth_request
560-
attempt = AtomicCounter(0)
560+
nth_request = database._next_nth_request
561+
attempt = AtomicCounter(0)
561562

562-
async def wrapped_method(*args, **kwargs):
563-
attempt.increment()
564-
call_metadata, error_augmenter = database.with_error_augmentation(
565-
nth_request, attempt.value, metadata
566-
)
567-
execute_sql_method = functools.partial(
568-
api.execute_sql,
569-
request=execute_sql_request,
570-
metadata=call_metadata,
571-
retry=retry,
572-
timeout=timeout,
573-
)
574-
with error_augmenter:
575-
return await execute_sql_method(*args, **kwargs)
576-
577-
result_set_pb: ResultSet = await self._execute_request(
578-
wrapped_method,
579-
execute_sql_request,
580-
metadata,
581-
f"CloudSpanner.{type(self).__name__}.execute_update",
582-
trace_attributes,
583-
)
563+
async def wrapped_method(*args, **kwargs):
564+
attempt.increment()
565+
call_metadata, error_augmenter = database.with_error_augmentation(
566+
nth_request, attempt.value, metadata
567+
)
568+
execute_sql_method = functools.partial(
569+
api.execute_sql,
570+
request=execute_sql_request,
571+
metadata=call_metadata,
572+
retry=retry,
573+
timeout=timeout,
574+
)
575+
with error_augmenter:
576+
return await execute_sql_method(*args, **kwargs)
584577

585-
self._update_for_result_set_pb(result_set_pb)
578+
result_set_pb: ResultSet = await self._execute_request(
579+
wrapped_method,
580+
execute_sql_request,
581+
metadata,
582+
f"CloudSpanner.{type(self).__name__}.execute_update",
583+
trace_attributes,
584+
)
586585

587-
if is_inline_begin:
588-
self._lock.release()
586+
self._update_for_result_set_pb(result_set_pb)
587+
finally:
588+
if is_inline_begin:
589+
self._lock.release()
589590

590591
if result_set_pb._pb.HasField("precommit_token"):
591592
await self._update_for_precommit_token_pb(result_set_pb.precommit_token)
@@ -703,45 +704,46 @@ async def batch_update(
703704
is_inline_begin = True
704705
await self._lock.acquire()
705706

706-
execute_batch_dml_request = ExecuteBatchDmlRequest(
707-
session=session.name,
708-
transaction=self._build_transaction_selector_pb(),
709-
statements=parsed,
710-
seqno=seqno,
711-
request_options=request_options,
712-
last_statements=last_statement,
713-
)
707+
try:
708+
execute_batch_dml_request = ExecuteBatchDmlRequest(
709+
session=session.name,
710+
transaction=self._build_transaction_selector_pb(),
711+
statements=parsed,
712+
seqno=seqno,
713+
request_options=request_options,
714+
last_statements=last_statement,
715+
)
714716

715-
nth_request = database._next_nth_request
716-
attempt = AtomicCounter(0)
717+
nth_request = database._next_nth_request
718+
attempt = AtomicCounter(0)
717719

718-
async def wrapped_method(*args, **kwargs):
719-
attempt.increment()
720-
call_metadata, error_augmenter = database.with_error_augmentation(
721-
nth_request, attempt.value, metadata
722-
)
723-
execute_batch_dml_method = functools.partial(
724-
api.execute_batch_dml,
725-
request=execute_batch_dml_request,
726-
metadata=call_metadata,
727-
retry=retry,
728-
timeout=timeout,
729-
)
730-
with error_augmenter:
731-
return await execute_batch_dml_method(*args, **kwargs)
732-
733-
response_pb: ExecuteBatchDmlResponse = await self._execute_request(
734-
wrapped_method,
735-
execute_batch_dml_request,
736-
metadata,
737-
"CloudSpanner.DMLTransaction",
738-
trace_attributes,
739-
)
720+
async def wrapped_method(*args, **kwargs):
721+
attempt.increment()
722+
call_metadata, error_augmenter = database.with_error_augmentation(
723+
nth_request, attempt.value, metadata
724+
)
725+
execute_batch_dml_method = functools.partial(
726+
api.execute_batch_dml,
727+
request=execute_batch_dml_request,
728+
metadata=call_metadata,
729+
retry=retry,
730+
timeout=timeout,
731+
)
732+
with error_augmenter:
733+
return await execute_batch_dml_method(*args, **kwargs)
740734

741-
self._update_for_execute_batch_dml_response_pb(response_pb)
735+
response_pb: ExecuteBatchDmlResponse = await self._execute_request(
736+
wrapped_method,
737+
execute_batch_dml_request,
738+
metadata,
739+
"CloudSpanner.DMLTransaction",
740+
trace_attributes,
741+
)
742742

743-
if is_inline_begin:
744-
self._lock.release()
743+
self._update_for_execute_batch_dml_response_pb(response_pb)
744+
finally:
745+
if is_inline_begin:
746+
self._lock.release()
745747

746748
if (
747749
len(response_pb.result_sets) > 0

‎packages/google-cloud-spanner/google/cloud/spanner_v1/transaction.py‎

Lines changed: 76 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -446,46 +446,48 @@ def execute_update(
446446
if self._transaction_id is None:
447447
is_inline_begin = True
448448
self._lock.acquire()
449-
execute_sql_request = ExecuteSqlRequest(
450-
session=session.name,
451-
transaction=self._build_transaction_selector_pb(),
452-
sql=dml,
453-
params=params_pb,
454-
param_types=param_types,
455-
query_mode=query_mode,
456-
query_options=query_options,
457-
seqno=seqno,
458-
request_options=request_options,
459-
last_statement=last_statement,
460-
)
461-
nth_request = database._next_nth_request
462-
attempt = AtomicCounter(0)
463-
464-
def wrapped_method(*args, **kwargs):
465-
attempt.increment()
466-
call_metadata, error_augmenter = database.with_error_augmentation(
467-
nth_request, attempt.value, metadata
449+
try:
450+
execute_sql_request = ExecuteSqlRequest(
451+
session=session.name,
452+
transaction=self._build_transaction_selector_pb(),
453+
sql=dml,
454+
params=params_pb,
455+
param_types=param_types,
456+
query_mode=query_mode,
457+
query_options=query_options,
458+
seqno=seqno,
459+
request_options=request_options,
460+
last_statement=last_statement,
468461
)
469-
execute_sql_method = functools.partial(
470-
api.execute_sql,
471-
request=execute_sql_request,
472-
metadata=call_metadata,
473-
retry=retry,
474-
timeout=timeout,
462+
nth_request = database._next_nth_request
463+
attempt = AtomicCounter(0)
464+
465+
def wrapped_method(*args, **kwargs):
466+
attempt.increment()
467+
call_metadata, error_augmenter = database.with_error_augmentation(
468+
nth_request, attempt.value, metadata
469+
)
470+
execute_sql_method = functools.partial(
471+
api.execute_sql,
472+
request=execute_sql_request,
473+
metadata=call_metadata,
474+
retry=retry,
475+
timeout=timeout,
476+
)
477+
with error_augmenter:
478+
return execute_sql_method(*args, **kwargs)
479+
480+
result_set_pb: ResultSet = self._execute_request(
481+
wrapped_method,
482+
execute_sql_request,
483+
metadata,
484+
f"CloudSpanner.{type(self).__name__}.execute_update",
485+
trace_attributes,
475486
)
476-
with error_augmenter:
477-
return execute_sql_method(*args, **kwargs)
478-
479-
result_set_pb: ResultSet = self._execute_request(
480-
wrapped_method,
481-
execute_sql_request,
482-
metadata,
483-
f"CloudSpanner.{type(self).__name__}.execute_update",
484-
trace_attributes,
485-
)
486-
self._update_for_result_set_pb(result_set_pb)
487-
if is_inline_begin:
488-
self._lock.release()
487+
self._update_for_result_set_pb(result_set_pb)
488+
finally:
489+
if is_inline_begin:
490+
self._lock.release()
489491
if result_set_pb._pb.HasField("precommit_token"):
490492
self._update_for_precommit_token_pb(result_set_pb.precommit_token)
491493
return result_set_pb.stats.row_count_exact
@@ -586,42 +588,44 @@ def batch_update(
586588
if self._transaction_id is None:
587589
is_inline_begin = True
588590
self._lock.acquire()
589-
execute_batch_dml_request = ExecuteBatchDmlRequest(
590-
session=session.name,
591-
transaction=self._build_transaction_selector_pb(),
592-
statements=parsed,
593-
seqno=seqno,
594-
request_options=request_options,
595-
last_statements=last_statement,
596-
)
597-
nth_request = database._next_nth_request
598-
attempt = AtomicCounter(0)
599-
600-
def wrapped_method(*args, **kwargs):
601-
attempt.increment()
602-
call_metadata, error_augmenter = database.with_error_augmentation(
603-
nth_request, attempt.value, metadata
591+
try:
592+
execute_batch_dml_request = ExecuteBatchDmlRequest(
593+
session=session.name,
594+
transaction=self._build_transaction_selector_pb(),
595+
statements=parsed,
596+
seqno=seqno,
597+
request_options=request_options,
598+
last_statements=last_statement,
604599
)
605-
execute_batch_dml_method = functools.partial(
606-
api.execute_batch_dml,
607-
request=execute_batch_dml_request,
608-
metadata=call_metadata,
609-
retry=retry,
610-
timeout=timeout,
600+
nth_request = database._next_nth_request
601+
attempt = AtomicCounter(0)
602+
603+
def wrapped_method(*args, **kwargs):
604+
attempt.increment()
605+
call_metadata, error_augmenter = database.with_error_augmentation(
606+
nth_request, attempt.value, metadata
607+
)
608+
execute_batch_dml_method = functools.partial(
609+
api.execute_batch_dml,
610+
request=execute_batch_dml_request,
611+
metadata=call_metadata,
612+
retry=retry,
613+
timeout=timeout,
614+
)
615+
with error_augmenter:
616+
return execute_batch_dml_method(*args, **kwargs)
617+
618+
response_pb: ExecuteBatchDmlResponse = self._execute_request(
619+
wrapped_method,
620+
execute_batch_dml_request,
621+
metadata,
622+
"CloudSpanner.DMLTransaction",
623+
trace_attributes,
611624
)
612-
with error_augmenter:
613-
return execute_batch_dml_method(*args, **kwargs)
614-
615-
response_pb: ExecuteBatchDmlResponse = self._execute_request(
616-
wrapped_method,
617-
execute_batch_dml_request,
618-
metadata,
619-
"CloudSpanner.DMLTransaction",
620-
trace_attributes,
621-
)
622-
self._update_for_execute_batch_dml_response_pb(response_pb)
623-
if is_inline_begin:
624-
self._lock.release()
625+
self._update_for_execute_batch_dml_response_pb(response_pb)
626+
finally:
627+
if is_inline_begin:
628+
self._lock.release()
625629
if (
626630
len(response_pb.result_sets) > 0
627631
and response_pb.result_sets[0].precommit_token

0 commit comments

Comments
 (0)