Skip to content

Commit cdd696a

Browse files
feat(Spanner): Add the RequestIdHeaderMiddleware class (#8764)
1 parent 7311a93 commit cdd696a

File tree

6 files changed

+550
-1
lines changed

6 files changed

+550
-1
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"php": "^8.1",
88
"ext-grpc": "*",
99
"google/cloud-core": "^1.68",
10-
"google/gax": "^1.38.1"
10+
"google/gax": "^1.40.0"
1111
},
1212
"require-dev": {
1313
"phpunit/phpunit": "^9.6",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
/*
3+
* Copyright 2025 Google LLC
4+
* All rights reserved.
5+
*
6+
* Redistribution and use in source and binary forms, with or without
7+
* modification, are permitted provided that the following conditions are
8+
* met:
9+
*
10+
* * Redistributions of source code must retain the above copyright
11+
* notice, this list of conditions and the following disclaimer.
12+
* * Redistributions in binary form must reproduce the above
13+
* copyright notice, this list of conditions and the following disclaimer
14+
* in the documentation and/or other materials provided with the
15+
* distribution.
16+
* * Neither the name of Google Inc. nor the names of its
17+
* contributors may be used to endorse or promote products derived from
18+
* this software without specific prior written permission.
19+
*
20+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31+
*/
32+
33+
namespace Google\Cloud\Spanner\Middleware;
34+
35+
use Google\ApiCore\Call;
36+
use Google\ApiCore\Middleware\MiddlewareInterface;
37+
38+
/**
39+
* Middleware that adds the RequestId header to each rpc call made by spanner
40+
*
41+
* @internal
42+
*/
43+
class RequestIdHeaderMiddleware implements MiddlewareInterface
44+
{
45+
private const REQUEST_ID_HEADER_NAME = 'x-goog-spanner-request-id';
46+
private const VERSION = 1;
47+
private static string $process;
48+
private static int $currentClient = 1;
49+
private int $client;
50+
private int $channel;
51+
private int $request = 0;
52+
53+
/** @var callable */
54+
private $nextHandler;
55+
56+
public function __construct(callable $nextHandler, int $channelId)
57+
{
58+
$this->nextHandler = $nextHandler;
59+
$this->channel = $channelId;
60+
$this->client = self::$currentClient++;
61+
}
62+
63+
public function __invoke(Call $call, array $options)
64+
{
65+
$options['headers'][self::REQUEST_ID_HEADER_NAME] = [$this->getNewHeaderValue($options)];
66+
$next = $this->nextHandler;
67+
return $next(
68+
$call,
69+
$options
70+
);
71+
}
72+
73+
/**
74+
* Returns a new Header value
75+
*
76+
* @param array $options The options passed to the middlewre from GAX.
77+
* @return string
78+
*/
79+
private function getNewHeaderValue(array $options): string
80+
{
81+
$template = '%s.%s.%s.%s.%s.%s';
82+
83+
$process = $this->getProcess();
84+
$client = $this->client;
85+
$channel = $this->channel;
86+
$attempt = $this->getAttempt($options);
87+
$request = $this->getNextRequestValue($attempt);
88+
89+
return sprintf($template, self::VERSION, $process, $client, $channel, $request, $attempt);
90+
}
91+
92+
/**
93+
* Gets the process id for the RequestId header.
94+
*
95+
* @return string
96+
*/
97+
private function getProcess(): string
98+
{
99+
if (empty(self::$process)) {
100+
$rawProcess = random_bytes(8);
101+
// We want a hex encoded value
102+
self::$process = bin2hex($rawProcess);
103+
}
104+
105+
return self::$process;
106+
}
107+
108+
/**
109+
* Reads the options array passed form GAX and looks for the `retryAttempt` value.
110+
* If none is found, returns 1, meaning this is the first attempt
111+
*
112+
* @param array $options The options passed in from GAX
113+
* @return int
114+
*/
115+
private function getAttempt(array $options): int
116+
{
117+
if (empty($options['retryAttempt'])) {
118+
return 1;
119+
}
120+
121+
return $options['retryAttempt'] + 1;
122+
}
123+
124+
private function getNextRequestValue(int $attempt): int
125+
{
126+
if ($attempt > 1) {
127+
return $this->request;
128+
}
129+
130+
$this->request++;
131+
$currentValue = $this->request;
132+
return $currentValue;
133+
}
134+
}

src/SpannerClient.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use Google\Cloud\Spanner\Admin\Instance\V1\ListInstancesRequest;
3939
use Google\Cloud\Spanner\Admin\Instance\V1\ReplicaInfo;
4040
use Google\Cloud\Spanner\Batch\BatchClient;
41+
use Google\Cloud\Spanner\Middleware\RequestIdHeaderMiddleware;
4142
use Google\Cloud\Spanner\Middleware\SpannerMiddleware;
4243
use Google\Cloud\Spanner\V1\Client\SpannerClient as GapicSpannerClient;
4344
use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel;
@@ -131,6 +132,8 @@ class SpannerClient
131132
private array $defaultQueryOptions;
132133
private int $isolationLevel;
133134
private CacheItemPoolInterface|null $cacheItemPool;
135+
private static array $activeChannels = [];
136+
private static int $totalActiveChannels = 0;
134137

135138
/**
136139
* Create a Spanner client. Please note that this client requires
@@ -250,6 +253,15 @@ public function __construct(array $options = [])
250253
$this->databaseAdminClient = $options['gapicSpannerDatabaseAdminClient']
251254
?? new DatabaseAdminClient($clientOptions);
252255

256+
$channelId = $this->getChannelId($options);
257+
// Add the RequestIdHeaderMiddleware to add an identifier to each rpc call made for debugging
258+
$requestIdMiddleware = function (MiddlewareInterface $handler) use ($channelId) {
259+
return new RequestIdHeaderMiddleware($handler, $channelId);
260+
};
261+
$this->spannerClient->prependMiddleware($requestIdMiddleware);
262+
$this->instanceAdminClient->prependMiddleware($requestIdMiddleware);
263+
$this->databaseAdminClient->prependMiddleware($requestIdMiddleware);
264+
253265
// Add the SpannerMiddleware, which wraps API Exceptions, and adds
254266
// Resource Prefix and LAR headers
255267
$middleware = function (MiddlewareInterface $handler) {
@@ -941,4 +953,33 @@ private function isGrpcLoaded()
941953
{
942954
return extension_loaded('grpc');
943955
}
956+
957+
/**
958+
* Returns the chanel ID to be used for the RequestIdHeaderMiddleware
959+
*
960+
* @param array $options
961+
* @return int
962+
*/
963+
private function getChannelId(array $options): int
964+
{
965+
$channel = $options['transportOptions']['grpc']['channel'] ?? null;
966+
967+
// There was no shared channel configured. Grpc will create a new one
968+
if (is_null($channel)) {
969+
self::$totalActiveChannels++;
970+
return self::$totalActiveChannels;
971+
}
972+
973+
$channelObjectId = spl_object_id($channel);
974+
975+
// There is a shared channel and it is the first time we've seen this specific channel.
976+
if (empty(self::$activeChannels[$channelObjectId])) {
977+
self::$totalActiveChannels++;
978+
self::$activeChannels[$channelObjectId] = self::$totalActiveChannels;
979+
return self::$totalActiveChannels;
980+
}
981+
982+
// We have seen this channel, get the ID assigned to the channel
983+
return self::$activeChannels[$channelObjectId];
984+
}
944985
}

tests/Snippet/CommitTimestampTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public function testClass()
5959

6060
$this->spannerClient->addMiddleware(Argument::type('callable'))
6161
->shouldBeCalledOnce();
62+
$this->spannerClient->prependMiddleware(Argument::type('callable'))
63+
->shouldBeCalledOnce();
6264

6365
// ensure cache hit
6466
$cacheItem = $this->prophesize(CacheItemInterface::class);

0 commit comments

Comments
 (0)