Skip to content

Commit 8a3db2c

Browse files
feat: Support for Spanner ReadLockMode (#8419)
* feat: Support for Spanner ReadLockMode --------- Co-authored-by: Purva Vasudeo <purvavasudeo@google.com>
1 parent 5103ddd commit 8a3db2c

File tree

5 files changed

+221
-21
lines changed

5 files changed

+221
-21
lines changed

Spanner/src/Connection/Grpc.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Google\ApiCore\Call;
2121
use Google\ApiCore\CredentialsWrapper;
2222
use Google\ApiCore\Serializer;
23+
use Google\ApiCore\ValidationException;
2324
use Google\Auth\GetUniverseDomainInterface;
2425
use Google\Cloud\Core\EmulatorTrait;
2526
use Google\Cloud\Core\GrpcRequestWrapper;
@@ -1104,6 +1105,12 @@ public function beginTransaction(array $args)
11041105
$readWrite = new ReadWrite();
11051106
$options->setReadWrite($readWrite);
11061107
$args = $this->addLarHeader($args, $this->larEnabled);
1108+
1109+
if (isset($transactionOptions['readWrite']['readLockMode'])) {
1110+
// Nested option `readLockMode` inside `readWrite` transactions
1111+
$readLockModeOption = $transactionOptions['readWrite']['readLockMode'];
1112+
$options->getReadWrite()->setReadLockMode($readLockModeOption);
1113+
}
11071114
} elseif (isset($transactionOptions['partitionedDml'])) {
11081115
$pdml = new PartitionedDml();
11091116
$options->setPartitionedDml($pdml);
@@ -1573,6 +1580,32 @@ private function formatTransactionOptions(array $transactionOptions)
15731580
$transactionOptions['readOnly'] = $ro;
15741581
}
15751582

1583+
if (isset($transactionOptions['readLockMode'])) {
1584+
if (isset($transactionOptions['readOnly'])) {
1585+
throw new ValidationException(
1586+
'The readLockMode option is only valid for readWrite transactions.'
1587+
);
1588+
}
1589+
1590+
if (!isset($transactionOptions['readWrite'])) {
1591+
$transactionOptions['readWrite'] = [];
1592+
}
1593+
}
1594+
1595+
if (isset($transactionOptions['readWrite'])) {
1596+
$rw = $transactionOptions['readWrite'];
1597+
1598+
// Format nested options inside readWrite transaction
1599+
if (isset($transactionOptions['readLockMode'])) {
1600+
$rw['readLockMode'] = $transactionOptions['readLockMode'];
1601+
1602+
// Unset the readLockMode key on the base options array.
1603+
// If we don't do this it causes issues in the serializer for TransactionOptions
1604+
unset($transactionOptions['readLockMode']);
1605+
}
1606+
1607+
$transactionOptions['readWrite'] = $rw;
1608+
}
15761609
return $transactionOptions;
15771610
}
15781611

Spanner/src/Database.php

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -775,10 +775,8 @@ public function snapshot(array $options = [])
775775
* If you wish Google Cloud PHP to handle retry logic for you (recommended
776776
* for most cases), use {@see \Google\Cloud\Spanner\Database::runTransaction()}.
777777
*
778-
* Please note that once a transaction reads data, it will lock the read
779-
* data, preventing other users from modifying that data. For this reason,
780-
* it is important that every transaction commits or rolls back as early as
781-
* possible. Do not hold transactions open longer than necessary.
778+
* Please note for locking semantics and defaults for the transactions
779+
* use {@see \Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode}
782780
*
783781
* Example:
784782
* ```
@@ -812,8 +810,8 @@ public function transaction(array $options = [])
812810
throw new \BadMethodCallException('Nested transactions are not supported by this client.');
813811
}
814812

815-
// There isn't anything configurable here.
816-
$options['transactionOptions'] = $this->configureTransactionOptions();
813+
// Configure readWrite options here. Any nested options for readWrite should be added to this call
814+
$options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []);
817815

818816
$session = $this->selectSession(
819817
SessionPoolInterface::CONTEXT_READWRITE,
@@ -840,10 +838,8 @@ public function transaction(array $options = [])
840838
* exception types will immediately bubble up and will interrupt the retry
841839
* operation.
842840
*
843-
* Please note that once a transaction reads data, it will lock the read
844-
* data, preventing other users from modifying that data. For this reason,
845-
* it is important that every transaction commits or rolls back as early as
846-
* possible. Do not hold transactions open longer than necessary.
841+
* Please note for locking semantics and defaults for the transactions
842+
* use {@see \Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode}
847843
*
848844
* Please also note that nested transactions are NOT supported by this client.
849845
* Attempting to call `runTransaction` inside a transaction callable will
@@ -920,7 +916,6 @@ public function runTransaction(callable $operation, array $options = [])
920916
'maxRetries' => self::MAX_RETRIES,
921917
];
922918

923-
// There isn't anything configurable here.
924919
$options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []);
925920

926921
$session = $this->selectSession(

Spanner/src/TransactionConfigurationTrait.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
use Google\Cloud\Core\ArrayTrait;
2121
use Google\Cloud\Spanner\Session\SessionPoolInterface;
22+
use Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode as ReadLockMode;
2223

2324
/**
2425
* Configure transaction selection for read, executeSql, rollback and commit.
@@ -152,6 +153,15 @@ private function configureTransactionOptions(array $options = [])
152153
$transactionOptions['excludeTxnFromChangeStreams'] = $options['excludeTxnFromChangeStreams'];
153154
}
154155

156+
// Allow for proper configuring of the `readLockMode` if it's set as a base or nested option
157+
if (isset($options['readLockMode'])) {
158+
$transactionOptions['readWrite']['readLockMode'] = $options['readLockMode'];
159+
}
160+
161+
if (isset($options['readWrite']['readLockMode'])) {
162+
$transactionOptions['readWrite']['readLockMode'] = $options['readWrite']['readLockMode'];
163+
}
164+
155165
return $transactionOptions;
156166
}
157167

Spanner/tests/Unit/DatabaseTest.php

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Google\Cloud\Core\Exception\AbortedException;
2222
use Google\Cloud\Core\Exception\NotFoundException;
2323
use Google\Cloud\Core\Exception\ServerException;
24+
use Google\Cloud\Core\Exception\ServiceException;
2425
use Google\Cloud\Core\Iam\Iam;
2526
use Google\Cloud\Core\Iterator\ItemIterator;
2627
use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface;
@@ -45,18 +46,18 @@
4546
use Google\Cloud\Spanner\Tests\StubCreationTrait;
4647
use Google\Cloud\Spanner\Timestamp;
4748
use Google\Cloud\Spanner\Transaction;
49+
use Google\Cloud\Spanner\V1\DirectedReadOptions\ReplicaSelection\Type as ReplicaType;
4850
use Google\Cloud\Spanner\V1\ResultSet;
4951
use Google\Cloud\Spanner\V1\ResultSetStats;
50-
use Google\Cloud\Spanner\V1\DirectedReadOptions\ReplicaSelection\Type as ReplicaType;
5152
use Google\Cloud\Spanner\V1\Session as SessionProto;
5253
use Google\Cloud\Spanner\V1\SpannerClient;
5354
use Google\Cloud\Spanner\V1\Transaction as TransactionProto;
5455
use Google\Cloud\Spanner\V1\TransactionOptions;
56+
use Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode as ReadLockMode;
5557
use Google\Rpc\Code;
5658
use PHPUnit\Framework\TestCase;
5759
use Prophecy\Argument;
5860
use Prophecy\PhpUnit\ProphecyTrait;
59-
use Google\Cloud\Core\Exception\ServiceException;
6061
use Google\Cloud\Spanner\V1\ReadRequest\LockHint;
6162
use Google\Cloud\Spanner\V1\ReadRequest\OrderBy;
6263

@@ -94,7 +95,6 @@ class DatabaseTest extends TestCase
9495
private $directedReadOptionsIncludeReplicas;
9596
private $directedReadOptionsExcludeReplicas;
9697

97-
9898
public function setUp(): void
9999
{
100100
$this->checkAndSkipGrpcTests();
@@ -253,7 +253,7 @@ public function testBackups()
253253
]
254254
];
255255

256-
$expectedFilter = "database:".$this->database->name();
256+
$expectedFilter = 'database:' . $this->database->name();
257257
$this->connection->listBackups(Argument::withEntry('filter', $expectedFilter))
258258
->shouldBeCalled()
259259
->willReturn(['backups' => $backups]);
@@ -281,8 +281,8 @@ public function testBackupsWithCustomFilter()
281281
'name' => DatabaseAdminClient::backupName(self::PROJECT, self::INSTANCE, 'backup2'),
282282
]
283283
];
284-
$defaultFilter = "database:" . $this->database->name();
285-
$customFilter = "customFilter";
284+
$defaultFilter = 'database:' . $this->database->name();
285+
$customFilter = 'customFilter';
286286
$expectedFilter = sprintf('(%1$s) AND (%2$s)', $defaultFilter, $customFilter);
287287

288288
$this->connection->listBackups(Argument::withEntry('filter', $expectedFilter))
@@ -412,7 +412,7 @@ public function testCreatePostgresDialect()
412412
$this->database->___setProperty('connection', $this->connection->reveal());
413413

414414
$op = $this->database->create([
415-
'databaseDialect'=> DatabaseDialect::POSTGRESQL
415+
'databaseDialect' => DatabaseDialect::POSTGRESQL
416416
]);
417417

418418
$this->assertInstanceOf(LongRunningOperation::class, $op);
@@ -710,7 +710,6 @@ public function testBatchWrite()
710710
Argument::withEntry('mutationGroups', [$expectedMutationGroup])
711711
))->shouldBeCalled()->willReturn(['foo result']);
712712

713-
714713
$mutationGroups = [
715714
($this->database->mutationGroup(false))
716715
->insertOrUpdate(
@@ -2193,6 +2192,90 @@ public function testBatchWriteWithExcludeTxnFromChangeStreams()
21932192
]);
21942193
}
21952194

2195+
public function testRunTransactionWithReadLockMode()
2196+
{
2197+
$expectedReadLockMode = ReadLockMode::OPTIMISTIC;
2198+
2199+
$gapic = $this->prophesize(SpannerClient::class);
2200+
2201+
$sessName = SpannerClient::sessionName(self::PROJECT, self::INSTANCE, self::DATABASE, self::SESSION);
2202+
$session = new SessionProto(['name' => $sessName]);
2203+
$resultSet = new ResultSet(['stats' => new ResultSetStats(['row_count_exact' => 0])]);
2204+
$gapic->createSession(Argument::cetera())->shouldBeCalled()->willReturn($session);
2205+
$gapic->deleteSession(Argument::cetera())->shouldBeCalled();
2206+
2207+
$sql = 'SELECT example FROM sql_query';
2208+
$stream = $this->prophesize(ServerStream::class);
2209+
$stream->readAll()->shouldBeCalledOnce()->willReturn([$resultSet]);
2210+
$gapic->executeStreamingSql(
2211+
$sessName,
2212+
$sql,
2213+
Argument::that(function (array $options) use ($expectedReadLockMode) {
2214+
$this->assertArrayHasKey('transaction', $options);
2215+
$this->assertNotNull($transactionOptions = $options['transaction']->getBegin());
2216+
$this->assertNotNull($readWriteTxnOptions = $transactionOptions->getReadWrite());
2217+
$this->assertNotNull($readLockModeOption = $readWriteTxnOptions->getReadLockMode());
2218+
$this->assertEquals(
2219+
$expectedReadLockMode,
2220+
$readLockModeOption
2221+
);
2222+
return true;
2223+
})
2224+
)
2225+
->shouldBeCalledOnce()
2226+
->willReturn($stream->reveal());
2227+
2228+
$database = new Database(
2229+
new Grpc(['gapicSpannerClient' => $gapic->reveal()]),
2230+
$this->instance,
2231+
$this->lro->reveal(),
2232+
$this->lroCallables,
2233+
self::PROJECT,
2234+
self::DATABASE
2235+
);
2236+
2237+
// Test TransactionOption array format with base level property set for readLockMode
2238+
// This helps test proper formating by the library to the format expected by Spanner backend
2239+
// (i.e. readLockMode should be inside readWrite)
2240+
$database->runTransaction(
2241+
function (Transaction $t) use ($sql) {
2242+
// Run a fake query
2243+
$t->executeUpdate($sql);
2244+
2245+
// Simulate calling Transaction::commmit()
2246+
$prop = new \ReflectionProperty($t, 'state');
2247+
$prop->setAccessible(true);
2248+
$prop->setValue($t, Transaction::STATE_COMMITTED);
2249+
},
2250+
['transactionOptions' => ['readLockMode' => $expectedReadLockMode, ] ]
2251+
);
2252+
}
2253+
2254+
public function testTransactionWithReadLockMode()
2255+
{
2256+
$expectedReadLockMode = ReadLockMode::OPTIMISTIC;
2257+
2258+
$this->connection->beginTransaction(
2259+
Argument::that(function (array $args) use ($expectedReadLockMode) {
2260+
$this->assertArrayHasKey('transactionOptions', $args);
2261+
$this->assertArrayHasKey('readWrite', $args['transactionOptions']);
2262+
$this->assertArrayHasKey('readLockMode', $args['transactionOptions']['readWrite']);
2263+
$this->assertEquals(
2264+
$expectedReadLockMode,
2265+
$args['transactionOptions']['readWrite']['readLockMode'],
2266+
"The read lock mode received was {$args['transactionOptions']['readWrite']['readLockMode']} " .
2267+
"does not match expected {$expectedReadLockMode}"
2268+
);
2269+
return true;
2270+
})
2271+
)
2272+
->shouldBeCalled()
2273+
->willReturn(['id' => self::TRANSACTION]);
2274+
2275+
$t = $this->database->transaction(['transactionOptions' => ['readLockMode' => $expectedReadLockMode, ]]);
2276+
$this->assertInstanceOf(Transaction::class, $t);
2277+
}
2278+
21962279
private function createStreamingAPIArgs()
21972280
{
21982281
$row = ['id' => 1];

Spanner/tests/Unit/OperationTest.php

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
use Google\ApiCore\ServerStream;
2121
use Google\Cloud\Core\Testing\GrpcTestTrait;
2222
use Google\Cloud\Core\Testing\TestHelpers;
23-
use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient;
2423
use Google\Cloud\Spanner\Batch\QueryPartition;
2524
use Google\Cloud\Spanner\Batch\ReadPartition;
2625
use Google\Cloud\Spanner\Connection\Grpc;
@@ -36,12 +35,12 @@
3635
use Google\Cloud\Spanner\Tests\StubCreationTrait;
3736
use Google\Cloud\Spanner\Timestamp;
3837
use Google\Cloud\Spanner\Transaction;
39-
use Google\Cloud\Spanner\V1\CommitResponse;
4038
use Google\Cloud\Spanner\V1\ResultSet;
4139
use Google\Cloud\Spanner\V1\ResultSetStats;
4240
use Google\Cloud\Spanner\V1\SpannerClient;
4341
use Google\Cloud\Spanner\V1\Transaction as TransactionProto;
4442
use Google\Cloud\Spanner\V1\TransactionOptions;
43+
use Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode as ReadLockMode;
4544
use PHPUnit\Framework\TestCase;
4645
use Prophecy\Argument;
4746
use Prophecy\PhpUnit\ProphecyTrait;
@@ -421,6 +420,86 @@ public function testExecuteAndExecuteUpdateWithExcludeTxnFromChangeStreams()
421420
]);
422421
}
423422

423+
public function testTransactionWithReadLockMode()
424+
{
425+
$expectedReadLockMode = ReadLockMode::OPTIMISTIC;
426+
$gapic = $this->prophesize(SpannerClient::class);
427+
$gapic->beginTransaction(
428+
self::SESSION,
429+
Argument::that(function (TransactionOptions $options) use ($expectedReadLockMode) {
430+
$this->assertNotNull($readWriteTxnOptions = $options->getReadWrite());
431+
$this->assertNotNull($readLockModeOption = $readWriteTxnOptions->getReadLockMode());
432+
$this->assertEquals(
433+
$expectedReadLockMode,
434+
$readLockModeOption
435+
);
436+
return true;
437+
}),
438+
Argument::type('array')
439+
)
440+
->shouldBeCalled()
441+
->willReturn(new TransactionProto(['id' => 'foo']));
442+
443+
$operation = new Operation(
444+
new Grpc(['gapicSpannerClient' => $gapic->reveal()]),
445+
true
446+
);
447+
448+
$transaction = $operation->transaction($this->session, [
449+
'transactionOptions' => ['readLockMode' => $expectedReadLockMode, ]
450+
]);
451+
452+
$this->assertEquals('foo', $transaction->id());
453+
}
454+
455+
public function testExecuteAndExecuteUpdateWithReadLockMode()
456+
{
457+
$expectedReadLockMode = ReadLockMode::OPTIMISTIC;
458+
$sql = 'SELECT example FROM sql_query';
459+
460+
$resultSet = new ResultSet(['stats' => new ResultSetStats(['row_count_exact' => 0])]);
461+
$stream = $this->prophesize(ServerStream::class);
462+
$stream->readAll()->shouldBeCalledTimes(2)->willReturn([$resultSet]);
463+
464+
$gapic = $this->prophesize(SpannerClient::class);
465+
$gapic->executeStreamingSql(
466+
self::SESSION,
467+
$sql,
468+
Argument::that(function (array $options) use ($expectedReadLockMode) {
469+
$this->assertArrayHasKey('transaction', $options);
470+
$this->assertNotNull($transactionOptions = $options['transaction']->getBegin());
471+
$this->assertNotNull($readWriteTxnOptions = $transactionOptions->getReadWrite());
472+
$this->assertNotNull($readLockModeOption = $readWriteTxnOptions->getReadLockMode());
473+
$this->assertEquals(
474+
$expectedReadLockMode,
475+
$readLockModeOption
476+
);
477+
return true;
478+
})
479+
)
480+
->shouldBeCalledTimes(2)
481+
->willReturn($stream->reveal());
482+
483+
$operation = new Operation(
484+
new Grpc(['gapicSpannerClient' => $gapic->reveal()]),
485+
true
486+
);
487+
488+
$lockModeOptions = [
489+
'transaction' => [
490+
'begin' => [
491+
'readLockMode' => $expectedReadLockMode,
492+
]
493+
]
494+
];
495+
496+
$operation->execute($this->session, $sql, $lockModeOptions);
497+
498+
$transaction = $this->prophesize(Transaction::class)->reveal();
499+
500+
$operation->executeUpdate($this->session, $transaction, $sql, $lockModeOptions);
501+
}
502+
424503
public function testSnapshot()
425504
{
426505
$this->connection->beginTransaction(Argument::allOf(

0 commit comments

Comments
 (0)