Skip to content

Commit e456865

Browse files
committed
feat(x-goog-spanner-request-id): implement request_id generation and propagation
Generates a request_id that is then injected inside metadata that's sent over to the Cloud Spanner backend. Fixes googleapis#1261
1 parent 259a78b commit e456865

File tree

8 files changed

+198
-1
lines changed

8 files changed

+198
-1
lines changed

google/cloud/spanner_v1/_helpers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import math
2020
import time
2121
import base64
22+
import threading
2223

2324
from google.protobuf.struct_pb2 import ListValue
2425
from google.protobuf.struct_pb2 import Value
@@ -525,3 +526,41 @@ def _metadata_with_leader_aware_routing(value, **kw):
525526
List[Tuple[str, str]]: RPC metadata with leader aware routing header
526527
"""
527528
return ("x-goog-spanner-route-to-leader", str(value).lower())
529+
530+
531+
class AtomicCounter:
532+
def __init__(self, start_value=0):
533+
self.__lock = threading.Lock()
534+
self.__value = start_value
535+
536+
@property
537+
def value(self):
538+
with self.__lock:
539+
return self.__value
540+
541+
def increment(self, n=1):
542+
with self.__lock:
543+
self.__value += n
544+
return self.__value
545+
546+
def __iadd__(self, n):
547+
"""
548+
Defines the inplace += operator result.
549+
"""
550+
with self.__lock:
551+
self.__value += n
552+
return self
553+
554+
def __add__(self, n):
555+
"""
556+
Defines the result of invoking: value = AtomicCounter + addable
557+
"""
558+
with self.__lock:
559+
n += self.__value
560+
return n
561+
562+
def __radd__(self, n):
563+
"""
564+
Defines the result of invoking: value = addable + AtomicCounter
565+
"""
566+
return self.__add__(n)

google/cloud/spanner_v1/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import grpc
2727
import os
2828
import warnings
29+
import threading
2930

3031
from google.api_core.gapic_v1 import client_info
3132
from google.auth.credentials import AnonymousCredentials
@@ -48,6 +49,7 @@
4849
from google.cloud.spanner_v1._helpers import _merge_query_options
4950
from google.cloud.spanner_v1._helpers import _metadata_with_prefix
5051
from google.cloud.spanner_v1.instance import Instance
52+
from google.cloud.spanner_v1._helpers import AtomicCounter
5153

5254
_CLIENT_INFO = client_info.ClientInfo(client_library_version=__version__)
5355
EMULATOR_ENV_VAR = "SPANNER_EMULATOR_HOST"
@@ -147,6 +149,8 @@ class Client(ClientWithProject):
147149
SCOPE = (SPANNER_ADMIN_SCOPE,)
148150
"""The scopes required for Google Cloud Spanner."""
149151

152+
NTH_CLIENT = AtomicCounter()
153+
150154
def __init__(
151155
self,
152156
project=None,
@@ -199,6 +203,12 @@ def __init__(
199203
self._route_to_leader_enabled = route_to_leader_enabled
200204
self._directed_read_options = directed_read_options
201205
self._observability_options = observability_options
206+
self._nth_client_id = Client.NTH_CLIENT.increment()
207+
self._nth_request = AtomicCounter()
208+
209+
@property
210+
def _next_nth_request(self):
211+
return self._nth_request.increment()
202212

203213
@property
204214
def credentials(self):

google/cloud/spanner_v1/database.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from google.cloud.spanner_v1 import SpannerClient
5151
from google.cloud.spanner_v1._helpers import _merge_query_options
5252
from google.cloud.spanner_v1._helpers import (
53+
AtomicCounter,
5354
_metadata_with_prefix,
5455
_metadata_with_leader_aware_routing,
5556
)
@@ -697,8 +698,15 @@ def execute_partitioned_dml(
697698
_metadata_with_leader_aware_routing(self._route_to_leader_enabled)
698699
)
699700

701+
nth_request = self._next_nth_request()
702+
attempt = AtomicCounter(1) # It'll be incremented inside _restart_on_unavailable
703+
700704
def execute_pdml():
701705
with SessionCheckout(self._pool) as session:
706+
channel_id = session._channel_id
707+
metadata = with_request_id(
708+
self._client._nth_client_id, nth_request, attempt.value, metadata
709+
)
702710
txn = api.begin_transaction(
703711
session=session.name, options=txn_options, metadata=metadata
704712
)
@@ -723,6 +731,7 @@ def execute_pdml():
723731
request=request,
724732
transaction_selector=txn_selector,
725733
observability_options=self.observability_options,
734+
attempt=attempt,
726735
)
727736

728737
result_set = StreamedResultSet(iterator)
@@ -732,6 +741,9 @@ def execute_pdml():
732741

733742
return _retry_on_aborted(execute_pdml, DEFAULT_RETRY_BACKOFF)()
734743

744+
def _next_nth_request(self):
745+
return self._instance._client._next_nth_request
746+
735747
def session(self, labels=None, database_role=None):
736748
"""Factory to create a session for this database.
737749

google/cloud/spanner_v1/pool.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import datetime
1818
import queue
1919
import time
20+
import threading
2021

2122
from google.cloud.exceptions import NotFound
2223
from google.cloud.spanner_v1 import BatchCreateSessionsRequest
@@ -52,6 +53,8 @@ def __init__(self, labels=None, database_role=None):
5253
labels = {}
5354
self._labels = labels
5455
self._database_role = database_role
56+
self.__lock = threading.lock()
57+
self._session_id_to_channel_id = dict()
5558

5659
@property
5760
def labels(self):
@@ -127,10 +130,17 @@ def _new_session(self):
127130
:rtype: :class:`~google.cloud.spanner_v1.session.Session`
128131
:returns: new session instance.
129132
"""
130-
return self._database.session(
133+
session = self._database.session(
131134
labels=self.labels, database_role=self.database_role
132135
)
133136

137+
with self.__lock:
138+
channel_id = len(self._session_id_to_channel_id) + 1
139+
self._session_id_to_channel_id[session._session.id] = channel_id
140+
session._channel_id = channel_id
141+
142+
return session
143+
134144
def session(self, **kwargs):
135145
"""Check out a session from the pool.
136146
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2024 Google LLC All rights reserved.
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+
import os
16+
import threading
17+
18+
REQ_ID_VERSION = 1 # The version of the x-goog-spanner-request-id spec.
19+
REQ_ID_HEADER_KEY = "x-goog-spanner-request-id"
20+
21+
22+
def generate_rand_uint64():
23+
b = os.urandom(8)
24+
return (
25+
b[7] & 0xFF
26+
| (b[6] & 0xFF) << 8
27+
| (b[5] & 0xFF) << 16
28+
| (b[4] & 0xFF) << 24
29+
| (b[3] & 0xFF) << 32
30+
| (b[2] & 0xFF) << 36
31+
| (b[1] & 0xFF) << 48
32+
| (b[0] & 0xFF) << 56
33+
)
34+
35+
36+
REQ_RAND_PROCESS_ID = generate_rand_uint64()
37+
38+
39+
def with_request_id(client_id, nth_request, attempt, other_metadata=[]):
40+
req_id = f"{REQ_ID_VERSION}.{REQ_RAND_PROCESS_ID}.{client_id}.{channel_id}.{nth_request}.{attempt}"
41+
other_metadata.append((REQ_ID_HEADER_KEY, req_id))
42+
return other_metadata

google/cloud/spanner_v1/session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(self, database, labels=None, database_role=None):
7575
self._labels = labels
7676
self._database_role = database_role
7777
self._last_use_time = datetime.utcnow()
78+
self.__channel_id = 0
7879

7980
def __lt__(self, other):
8081
return self._session_id < other._session_id

google/cloud/spanner_v1/snapshot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _restart_on_unavailable(
5858
transaction=None,
5959
transaction_selector=None,
6060
observability_options=None,
61+
attempt=0,
6162
):
6263
"""Restart iteration after :exc:`.ServiceUnavailable`.
6364
@@ -92,6 +93,7 @@ def _restart_on_unavailable(
9293
):
9394
iterator = method(request=request)
9495
while True:
96+
attempt += 1
9597
try:
9698
for item in iterator:
9799
item_buffer.append(item)

tests/unit/test_atomic_counter.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright 2024 Google LLC All rights reserved.
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+
import time
16+
import random
17+
import threading
18+
import unittest
19+
from google.cloud.spanner_v1._helpers import AtomicCounter
20+
21+
class TestAtomicCounter(unittest.TestCase):
22+
def test_initialization(self):
23+
ac_default = AtomicCounter()
24+
assert ac_default.value == 0
25+
26+
ac_1 = AtomicCounter(1)
27+
assert ac_1.value == 1
28+
29+
ac_negative_1 = AtomicCounter(-1)
30+
assert ac_negative_1.value == -1
31+
32+
def test_increment(self):
33+
ac = AtomicCounter()
34+
result_default = ac.increment()
35+
assert result_default == 1
36+
assert ac.value == 1
37+
38+
result_with_value = ac.increment(2)
39+
assert result_with_value == 3
40+
assert ac.value == 3
41+
result_plus_100 = ac.increment(100)
42+
assert result_plus_100 == 103
43+
44+
def test_plus_call(self):
45+
ac = AtomicCounter()
46+
ac += 1
47+
assert ac.value == 1
48+
49+
n = ac + 2
50+
assert n == 3
51+
assert ac.value == 1
52+
53+
n = 200 + ac
54+
assert n == 201
55+
assert ac.value == 1
56+
57+
58+
def test_multiple_threads_incrementing(self):
59+
ac = AtomicCounter()
60+
n = 200
61+
m = 10
62+
63+
def do_work():
64+
for i in range(m):
65+
ac.increment()
66+
67+
threads = []
68+
for i in range(n):
69+
th = threading.Thread(target=do_work)
70+
threads.append(th)
71+
th.start()
72+
73+
time.sleep(0.3)
74+
75+
random.shuffle(threads)
76+
for th in threads:
77+
th.join()
78+
assert th.is_alive() == False
79+
80+
# Finally the result should be n*m
81+
assert ac.value == n*m

0 commit comments

Comments
 (0)