diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/CloudSpannerFixtureBase.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/CloudSpannerFixtureBase.cs index fd5b85859684..1367f7ff9cfb 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/CloudSpannerFixtureBase.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/CloudSpannerFixtureBase.cs @@ -14,8 +14,10 @@ using Google.Cloud.ClientTesting; using Google.Cloud.Spanner.Common.V1; +using Google.Cloud.Spanner.V1; using Google.Cloud.Spanner.V1.Internal.Logging; using System; +using System.Threading.Tasks; namespace Google.Cloud.Spanner.Data.CommonTesting; @@ -39,4 +41,6 @@ public abstract class CloudSpannerFixtureBase : CloudProjectFixtureBa protected CloudSpannerFixtureBase(Func databaseFactory) => Database = databaseFactory(ProjectId); public SpannerConnection GetConnection(Logger logger = null, bool logCommitStats = false) => Database.GetConnection(logger, logCommitStats); + + public async Task GetManagedSession() => await Database.GetManagedSession().ConfigureAwait(false); } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/SpannerTestDatabaseBase.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/SpannerTestDatabaseBase.cs index dbe3ce8299d4..24677bb2f713 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/SpannerTestDatabaseBase.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.CommonTesting/SpannerTestDatabaseBase.cs @@ -16,9 +16,11 @@ using Google.Api.Gax.ResourceNames; using Google.Cloud.Spanner.Admin.Instance.V1; using Google.Cloud.Spanner.Common.V1; +using Google.Cloud.Spanner.V1; using Google.Cloud.Spanner.V1.Internal.Logging; using Grpc.Core; using System; +using System.Threading.Tasks; namespace Google.Cloud.Spanner.Data.CommonTesting; @@ -27,6 +29,8 @@ namespace Google.Cloud.Spanner.Data.CommonTesting; /// public abstract class SpannerTestDatabaseBase { + private ManagedSession _multiplexSession; + /// /// The Spanner Host name to connect to. It is read from the environment variable "TEST_SPANNER_HOST". /// @@ -175,7 +179,31 @@ protected void MaybeCreateInstanceOnEmulator(string projectId) public SpannerConnection GetConnection(Logger logger, bool logCommitStats = false) => new SpannerConnection(new SpannerConnectionStringBuilder(ConnectionString) { - SessionPoolManager = SessionPoolManager.Create(new V1.SessionPoolOptions(), logger), + SessionPoolManager = SessionPoolManager.Create(new ManagedSessionOptions(), logger), LogCommitStats = logCommitStats }); + + public async Task GetManagedSession() + { + if (_multiplexSession != null && GetEnvironmentVariableOrDefault("SPANNER_EMULATOR_HOST", null) == null) + { + // Only return the same multiplex session if we are NOT testing on the emulator + // The emulator does not handle concurrent transactions on a single multiplex session well + return _multiplexSession; + } + + var options = new ManagedSessionOptions(); + + _multiplexSession = await CreateMultiplexSession(options).ConfigureAwait(false); + + return _multiplexSession; + } + + private async Task CreateMultiplexSession(ManagedSessionOptions options) + { + var poolManager = SessionPoolManager.Create(options); + var muxSession = await poolManager.AcquireManagedSessionAsync(SpannerClientCreationOptions, DatabaseName, null); + + return muxSession; + } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/AdminTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/AdminTests.cs index af5608be00f1..c68d972d970d 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/AdminTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/AdminTests.cs @@ -95,8 +95,6 @@ StringValue STRING(MAX), var dropCommand = connection.CreateDdlCommand($"DROP DATABASE {dbName}"); await dropCommand.ExecuteNonQueryAsync(); } - - await SessionPoolHelpers.ShutdownPoolAsync(builder.WithDatabase(dbName)); } [Fact] @@ -146,8 +144,6 @@ StringValue STRING(MAX), var dropCommand = connection.CreateDdlCommand($"DROP DATABASE {dbName}"); await dropCommand.ExecuteNonQueryAsync(); } - - await SessionPoolHelpers.ShutdownPoolAsync(builder.WithDatabase(dbName)); } [Fact] diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/BatchDmlTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/BatchDmlTests.cs index 155ac875c94f..0c963ded5515 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/BatchDmlTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/BatchDmlTests.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Google LLC +// Copyright 2018 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/MutationsTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/MutationsTests.cs index 2b3046f29c6f..16e3786d9969 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/MutationsTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/MutationsTests.cs @@ -65,7 +65,8 @@ private async Task AssertEmptyMutationsFailAsync(SpannerCommand emptyMutation) // "the amount of values does not match the number of columns in the key". if (!_fixture.RunningOnEmulator) // The message is different on the emulator. { - Assert.Contains("does not specify any value", exception.RpcException.Message); + // This error is expected for Multiplex Sesions as they expect a mutation key during commit for mutation only transactions + Assert.Contains("Failed to initialize transaction due to invalid mutation key.", exception.RpcException.Message); } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/ReadTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/ReadTests.cs index 2104934f05c6..8a0423262aa3 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/ReadTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/ReadTests.cs @@ -71,10 +71,6 @@ public async Task BadDbName() Assert.Equal(ErrorCode.NotFound, e.ErrorCode); Assert.False(e.IsTransientSpannerFault()); } - - // Shut the pool associated with the bad database down, to avoid seeing spurious connection failures - // later in the log. - await SessionPoolHelpers.ShutdownPoolAsync(connectionString); } [Fact] diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/SpannerStressTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/SpannerStressTests.cs index a5d5ab1782c9..532556b96074 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/SpannerStressTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/SpannerStressTests.cs @@ -150,13 +150,7 @@ private async Task RunStress(Func func, in // The maximum roundtrip time for Spanner (and MySQL) is about 200ms per // write. If we initialize with the target sustained # sessions, // we shouldn't see any more sessions created. - int countToPreWarm = Math.Min(TargetQps / 4, 800); - var options = new SessionPoolOptions - { - MaximumActiveSessions = Math.Max(countToPreWarm + 50, 400), - MinimumPooledSessions = countToPreWarm, - MaximumConcurrentSessionCreates = Math.Min(countToPreWarm, 50) - }; + var options = new ManagedSessionOptions(); var sessionPoolManager = SessionPoolManager.Create(options); var connectionStringBuilder = new SpannerConnectionStringBuilder(_fixture.ConnectionString) @@ -164,16 +158,13 @@ private async Task RunStress(Func func, in SessionPoolManager = sessionPoolManager, MaximumGrpcChannels = Math.Max(4, 8 * TargetQps / 2000) }; - var pool = await connectionStringBuilder.AcquireSessionPoolAsync(); + var managedSession = await connectionStringBuilder.AcquireManagedSessionAsync(); var logger = Logger.DefaultLogger; logger.ResetPerformanceData(); logger.Info("Prewarming session pool for stress test"); // Prewarm step: allow up to 30 seconds for the session pool to be populated. var cancellationToken = new CancellationTokenSource(30000).Token; - await pool.WhenPoolReady(_fixture.DatabaseName, cancellationToken); - - logger.Info($"Prewarm complete. Pool stats: {pool.GetSegmentStatisticsSnapshot(_fixture.DatabaseName)}"); // Now run the test, with performance logging enabled, but without debug logging. // (Debug logging can write a lot to our log file, breaking the test.) @@ -193,8 +184,6 @@ private async Task RunStress(Func func, in } logger.Info($"Spanner latency = {latencyMs}ms"); - await SessionPoolHelpers.ShutdownPoolAsync(connectionStringBuilder); - // Spanner latency with 100 qps simulated is usually around 75ms. // We allow for a latency multiplier from callers, because callers may be executing, // more than one command. In particular, with inline transactions, mutation commits diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/TransactionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/TransactionTests.cs index 6a34319d7a4f..8b17911e72db 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/TransactionTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/TransactionTests.cs @@ -86,50 +86,6 @@ private async Task IncrementByOneAsync(SpannerConnection connection, bool orphan } } - [Fact] - public async Task Commit_ReturnsToPool() - { - using var connection = new SpannerConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - using var transaction = await connection.BeginTransactionAsync(); - using var command = connection.CreateSelectCommand($"SELECT Int64Value FROM {_fixture.TableName} WHERE K=@k"); - command.Parameters.Add("k", SpannerDbType.String, _key); - command.Transaction = transaction; - - var value = await command.ExecuteScalarAsync(); - - transaction.Commit(); - - var poolStatistics = connection.GetSessionPoolSegmentStatistics(); - - // Because the session is eagerly returned to the pool after a commit, there shouldn't - // be any active sessions even before we dispose of the transaction explicitly. - Assert.Equal(0, poolStatistics.ActiveSessionCount); - } - - [Fact] - public async Task Rollback_ReturnsToPool() - { - using var connection = new SpannerConnection(_fixture.ConnectionString); - await connection.OpenAsync(); - - using var transaction = await connection.BeginTransactionAsync(); - using var command = connection.CreateSelectCommand($"SELECT Int64Value FROM {_fixture.TableName} WHERE K=@k"); - command.Parameters.Add("k", SpannerDbType.String, _key); - command.Transaction = transaction; - - var value = await command.ExecuteScalarAsync(); - - transaction.Rollback(); - - var poolStatistics = connection.GetSessionPoolSegmentStatistics(); - - // Because the session is eagerly returned to the pool after a rollback, there shouldn't - // be any active sessions even before we dispose of the transaction explicitly. - Assert.Equal(0, poolStatistics.ActiveSessionCount); - } - [Fact] public async Task DetachOnDisposeTransactionIsDetached() { diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/Internal/ExecuteHelperTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/Internal/ExecuteHelperTests.cs index 9382abd73c78..5f54f921d0f3 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/Internal/ExecuteHelperTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/Internal/ExecuteHelperTests.cs @@ -30,37 +30,17 @@ public class ExecuteHelperTests public ExecuteHelperTests(SpannerDatabaseFixture fixture) => _fixture = fixture; - [Fact] - public Task SessionNotFound() => WithSessionPool(async pool => - { - var session = await pool.Client.CreateSessionAsync(_fixture.DatabaseName); - await pool.Client.DeleteSessionAsync(session.SessionName); - - // The session doesn't become invalid immediately after deletion. - // Wait for a minute to ensure the session is really expired. - await Task.Delay(TimeSpan.FromMinutes(1)); - - var request = new ExecuteSqlRequest - { - Sql = $"SELECT 1", - Session = session.Name - }; - var exception = await Assert.ThrowsAsync(() => pool.Client.ExecuteSqlAsync(request)); - Assert.True(ExecuteHelper.IsSessionExpiredError(exception)); - }); - // This code is separated out in case we need more tests. It's really just fluff. - private async Task WithSessionPool(Func action) + private async Task WithManagedSession(Func action) { var builder = new SpannerConnectionStringBuilder(_fixture.ConnectionString); - var pool = await builder.AcquireSessionPoolAsync(); + var managedSession = await builder.AcquireManagedSessionAsync(); try { - await action(pool); + await action(managedSession); } finally { - builder.SessionPoolManager.Release(pool); } } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ManagedSessionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ManagedSessionTests.cs new file mode 100644 index 000000000000..31843cb0ef34 --- /dev/null +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ManagedSessionTests.cs @@ -0,0 +1,248 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Api.Gax.Grpc; +using Google.Cloud.ClientTesting; +using Google.Cloud.Spanner.Data.CommonTesting; +using Google.Cloud.Spanner.V1; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Google.Cloud.Spanner.Data.IntegrationTests; +[Collection(nameof(AllTypesTableFixture))] +[CommonTestDiagnostics] +public class ManagedSessionTests +{ + private readonly AllTypesTableFixture _fixture; + + public ManagedSessionTests(AllTypesTableFixture fixture) => + _fixture = fixture; + + [Fact] + [Trait(Constants.SupportedOnEmulator, Constants.No)] + public async Task SessionCreationSucceeds() + { + ManagedSession muxSession = await _fixture.GetManagedSession(); + + Assert.NotNull(muxSession.Session); + Assert.NotNull(muxSession.SessionName); + + // Use the underlying client to get the mux session from the server. + SpannerClient client = muxSession.Client; + var getSessionRequest = new GetSessionRequest + { + SessionName = muxSession.SessionName, + }; + var matchingSession = client.GetSession(getSessionRequest); + + Assert.Equal(muxSession.SessionName, matchingSession.SessionName); + Assert.True(matchingSession.Multiplexed); + } + + [Fact] + [Trait(Constants.SupportedOnEmulator, Constants.No)] + public async Task RunReadWriteTransactionWithMultipleQueries() + { + ManagedSession multiplexSession = await _fixture.GetManagedSession(); + ManagedTransaction transaction = new ManagedTransaction(multiplexSession, null, new TransactionOptions { ReadWrite = new TransactionOptions.Types.ReadWrite() }, false, null); + String uniqueRowId = IdGenerator.FromGuid(); + // Query 1: Read some data before modification. + var result = await ExecuteSelectQuery(transaction, uniqueRowId); + Assert.NotNull(result); + Assert.NotNull(transaction.PrecommitToken); + Assert.NotNull(transaction.TransactionId); + + int preCommitTokenSeqNumber = transaction.PrecommitToken.SeqNum; + + // Query 2: Insert a new record. + result = await ExecuteInsertInt64Value(transaction, uniqueRowId, 10); + Assert.NotNull(result); + Assert.NotNull(transaction.PrecommitToken); + Assert.NotNull(transaction.TransactionId); + Assert.True(transaction.PrecommitToken.SeqNum >= preCommitTokenSeqNumber); + + preCommitTokenSeqNumber = transaction.PrecommitToken.SeqNum; + + // Commit the transaction + var commitResponse = await transaction.CommitAsync(new CommitRequest(), null); + Assert.NotNull(commitResponse); + Assert.NotNull(transaction.TransactionId); + } + + [Fact] + [Trait(Constants.SupportedOnEmulator, Constants.No)] + public async Task TestMultipleTransactionWritesOnSameSession() + { + ManagedSession multiplexSession = await _fixture.GetManagedSession(); + const int concurrentThreads = 5; + String uniqueRowId = IdGenerator.FromGuid(); + + try + { + var transactions = new ManagedTransaction[concurrentThreads]; + for (var i = 0; i < concurrentThreads; i++) + { + transactions[i] = new ManagedTransaction(multiplexSession, null, new TransactionOptions { ReadWrite = new TransactionOptions.Types.ReadWrite() }, false, null); + } + + for (var i = 0; i < concurrentThreads; i++) + { + await IncrementByOneAsync(transactions[i], uniqueRowId); + } + + ManagedTransaction fetchResultsTransaction = new ManagedTransaction(multiplexSession, null, new TransactionOptions { ReadWrite = new TransactionOptions.Types.ReadWrite() }, false, null); + var fetched = await ExecuteSelectQuery(fetchResultsTransaction, uniqueRowId); + + var row = fetched.Rows.First(); + var actual = long.Parse(row.Values[1].StringValue); + Assert.Equal(5, actual); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + Console.WriteLine(ex.InnerException?.ToString()); + throw; + } + } + + private async Task IncrementByOneAsync(ManagedTransaction transaction, string uniqueRowId, bool orphanTransaction = false) + { + var retrySettings = RetrySettings.FromExponentialBackoff( + maxAttempts: int.MaxValue, + initialBackoff: TimeSpan.FromMilliseconds(250), + maxBackoff: TimeSpan.FromSeconds(5), + backoffMultiplier: 1.5, + retryFilter: ignored => false, + RetrySettings.RandomJitter); + TimeSpan nextDelay = TimeSpan.Zero; + SpannerException spannerException; + DateTime deadline = DateTime.UtcNow.AddSeconds(30); + + while (true) + { + spannerException = null; + try + { + // We use manually created transactions here so the tests run on .NET Core. + long current; + + var fetched = await ExecuteSelectQuery(transaction, uniqueRowId); + if (fetched?.Rows.Any() == true) + { + var row = fetched.Rows.First(); + current = long.Parse(row.Values[1].StringValue); + } + else + { + current = 0L; + } + + + ResultSet result; + if (current == 0) + { + result = await ExecuteInsertInt64Value(transaction, uniqueRowId, current + 1); + } + else + { + result = await ExecuteUpdateInt64Value(transaction, uniqueRowId, current + 1); + } + + await transaction.CommitAsync(new CommitRequest(), null); + return; + } + // Keep trying for up to 30 seconds + catch (SpannerException ex) when (ex.IsRetryable && DateTime.UtcNow < deadline) + { + nextDelay = retrySettings.NextBackoff(nextDelay); + await Task.Delay(retrySettings.BackoffJitter.GetDelay(nextDelay)); + spannerException = ex; + } + } + } + + private async Task ExecuteSelectQuery(ManagedTransaction transaction, String uniqueRowId) + { + var selectParams = new Dictionary + { + { "id", new SpannerParameter { Value = Value.ForString(uniqueRowId) } } + }; + var selectSql = $"SELECT K, Int64Value FROM {_fixture.TableName} WHERE K = @id"; + var request = new ExecuteSqlRequest + { + Sql = selectSql, + Params = CreateStructFromParameters(selectParams), + }; + + return await transaction.ExecuteSqlAsync(request, null); + } + + private async Task ExecuteInsertInt64Value(ManagedTransaction transaction, String uniqueRowId, long insertValue) + { + var insertSql = $"INSERT {_fixture.TableName} (K, Int64Value) VALUES (@k, @int64Value)"; + var insertParams = new Dictionary + { + { "k", new SpannerParameter { Value = Value.ForString(uniqueRowId) } }, + { "int64Value", new SpannerParameter("int64Value", SpannerDbType.Int64, insertValue) } + }; + + var request = new ExecuteSqlRequest + { + Sql = insertSql, + Params = CreateStructFromParameters(insertParams), + }; + return await transaction.ExecuteSqlAsync(request, null); + } + + private async Task ExecuteUpdateInt64Value(ManagedTransaction transaction, String uniqueRowId, long updateValue) + { + var updateSql = $"UPDATE {_fixture.TableName} SET Int64Value = @newIntValue WHERE K = @id"; + var updateParams = new Dictionary + { + { "newIntValue", new SpannerParameter("newIntValue", SpannerDbType.Int64, updateValue) }, + { "id", new SpannerParameter { Value = Value.ForString(uniqueRowId) } } + }; + + var request = new ExecuteSqlRequest + { + Sql = updateSql, + Params = CreateStructFromParameters(updateParams), + }; + return await transaction.ExecuteSqlAsync(request, null); + } + + /// + /// Converts a dictionary of Spanner parameters to a Google.Protobuf.WellKnownTypes.Struct. + /// + private Struct CreateStructFromParameters(Dictionary parameters) + { + var pbStruct = new Struct(); + var options = SpannerConversionOptions.Default; + if (parameters != null) + { + foreach (var param in parameters) + { + var parameter = param.Value; + var protobufValue = parameter.GetConfiguredSpannerDbType(options).ToProtobufValue(parameter.GetValidatedValue()); + pbStruct.Fields.Add(param.Key, protobufValue); + } + } + return pbStruct; + } +} diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ReliableStreamReaderTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ReliableStreamReaderTests.cs index be3b9ac31536..4be46857701b 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ReliableStreamReaderTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/V1/ReliableStreamReaderTests.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Google LLC +// Copyright 2019 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ using Google.Cloud.Spanner.Data; using Google.Cloud.Spanner.Data.IntegrationTests; +using Google.Cloud.Spanner.V1.Internal.Logging; using Google.Protobuf.WellKnownTypes; using System.Threading.Tasks; using Xunit; @@ -41,12 +42,14 @@ public async Task HasDataAsync(string key, int expectedValueCount) ParamTypes = { { "Key", new Type { Code = TypeCode.String } } } }; var builder = new SpannerConnectionStringBuilder(_fixture.ConnectionString); - var pool = await builder.AcquireSessionPoolAsync(); + var managedSession = await builder.AcquireManagedSessionAsync(); try { - using (var pooledSession = await pool.AcquireSessionAsync(_fixture.DatabaseName, null, default)) + using (SpannerConnection connection = new SpannerConnection(builder)) { - using (var reader = pooledSession.ExecuteSqlStreamReader(request, null)) + await connection.OpenAsync(default); + ManagedTransaction managedTransaction = connection.AcquireManagedTransaction(null, out _); + using (var reader = await managedTransaction.ExecuteSqlStreamReaderAsync(request, null)) { // While there are more values to read, HasDataAsync should return true for (int valuesRead = 0; valuesRead < expectedValueCount; valuesRead++) @@ -69,7 +72,7 @@ public async Task HasDataAsync(string key, int expectedValueCount) } finally { - builder.SessionPoolManager.Release(pool); + // Nothing to clean here } } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/WriteTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/WriteTests.cs index 190b59d3ef05..29bfce36fd19 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/WriteTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.IntegrationTests/WriteTests.cs @@ -320,7 +320,16 @@ public async Task BadColumnName() cmd.Parameters.Add("badjuju", SpannerDbType.String, IdGenerator.FromGuid()); var e = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsyncWithRetry()); Logger.DefaultLogger.Debug($"BadColumnName: Caught error code: {e.ErrorCode}"); - Assert.Equal(ErrorCode.NotFound, e.ErrorCode); + if (_fixture.RunningOnEmulator) + { + // Emulator vs Prod give different exceptions for this case with Multiplex Sessions + Assert.Equal(ErrorCode.InvalidArgument, e.ErrorCode); + } + else + { + Assert.Equal(ErrorCode.NotFound, e.ErrorCode); + } + Assert.False(e.IsTransientSpannerFault()); } } @@ -334,7 +343,7 @@ public async Task BadColumnType() cmd.Parameters.Add("K", SpannerDbType.Float64, 0.1); var e = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsyncWithRetry()); Logger.DefaultLogger.Debug($"BadColumnType: Caught error code: {e.ErrorCode}"); - Assert.Equal(ErrorCode.FailedPrecondition, e.ErrorCode); + Assert.Equal(ErrorCode.InvalidArgument, e.ErrorCode); Assert.False(e.IsTransientSpannerFault()); } } @@ -348,7 +357,17 @@ public async Task BadTableName() cmd.Parameters.Add("K", SpannerDbType.String, IdGenerator.FromGuid()); var e = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsyncWithRetry()); Logger.DefaultLogger.Debug($"BadTableName: Caught error code: {e.ErrorCode}"); - Assert.Equal(ErrorCode.NotFound, e.ErrorCode); + + if(_fixture.RunningOnEmulator) + { + // Emulator vs Prod give different exceptions for this case with Multiplex Sessions + Assert.Equal(ErrorCode.InvalidArgument, e.ErrorCode); + } + else + { + Assert.Equal(ErrorCode.NotFound, e.ErrorCode); + } + Assert.False(e.IsTransientSpannerFault()); } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/RetriableTransactionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/RetriableTransactionTests.cs new file mode 100644 index 000000000000..1d1a16406341 --- /dev/null +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/RetriableTransactionTests.cs @@ -0,0 +1,67 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Api.Gax; +using Google.Cloud.Spanner.V1; +using Google.Cloud.Spanner.V1.Internal.Logging; +using Google.Cloud.Spanner.V1.Tests; +using Google.Protobuf; +using Grpc.Core; +using System; +using System.Data.SqlTypes; +using System.Threading.Tasks; +using System.Transactions; +using Xunit; + +namespace Google.Cloud.Spanner.Data.Tests +{ + public class RetriableTransactionTests + { + [Fact] + public async Task TestUpdatingPrevTransactionId() + { + Console.WriteLine("Starting TestUpdatingPrevTransactionId"); + SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); + spannerClientMock.SetupMultiplexSessionCreationAsync() + .SetupExecuteBatchDmlAsync() + .SetupCommitAsync_Fails(failures: 1, statusCode: StatusCode.Aborted, exceptionRetryDelay: TimeSpan.FromMilliseconds(300)) + .SetupRollbackAsync(); + + using (var connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock)) + { + await connection.EnsureIsOpenAsync(default); + var options = SpannerTransactionCreationOptions.ReadWrite; + var retriableTransaction = new RetriableTransaction(connection, SystemClock.Instance, SystemScheduler.Instance, options, null, null); + + Assert.Equal(ByteString.Empty, retriableTransaction._prevTransactionId); + + await retriableTransaction.RunAsync(async transaction => { + + Assert.Equal(retriableTransaction._prevTransactionId, transaction._creationOptions.PreviousTransactionId); + Assert.Equal(retriableTransaction._prevTransactionId, transaction._transaction.TransactionOptions.ReadWrite.MultiplexedSessionPreviousTransactionId); + + var dml = transaction.CreateBatchDmlCommand(); + dml.Add("UPDATE table_1 SET column_1 = column1 + 5"); + await dml.ExecuteNonQueryAsync(); + + return 12; // random int value to pacify compiler warning + }); + + Assert.Equal(SpannerClientHelpers.s_transactionId, retriableTransaction._prevTransactionId); + Console.WriteLine("Finished TestUpdatingPrevTransactionId"); + } + } + + } +} diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerBatchCommandTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerBatchCommandTests.cs index b77281883da6..815710229ffe 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerBatchCommandTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerBatchCommandTests.cs @@ -162,7 +162,7 @@ public void CommandPriorityDefaultsToUnspecified() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync(); + .SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction(); var command = transaction.CreateBatchDmlCommand(); @@ -175,7 +175,7 @@ public async Task CommandIncludesPriority() var priority = Priority.High; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); @@ -198,7 +198,7 @@ public async Task EphemeralTransactionIncludesPriorityOnBatchDmlAndCommit() var priority = Priority.Medium; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); @@ -224,7 +224,7 @@ await spannerClientMock.Received(1).CommitAsync( public void MaxCommitDelay_DefaultsToNull() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); var command = connection.CreateBatchDmlCommand(); @@ -235,7 +235,7 @@ public void MaxCommitDelay_DefaultsToNull() public void MaxCommitDelay_Valid(TimeSpan? maxCommitDelay) { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); var command = connection.CreateBatchDmlCommand(); @@ -248,7 +248,7 @@ public void MaxCommitDelay_Valid(TimeSpan? maxCommitDelay) public void MaxCommitDelay_Invalid(TimeSpan? maxCommitDelay) { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); var command = connection.CreateBatchDmlCommand(); @@ -260,7 +260,7 @@ public async Task MaxCommitDelay_DefaultsToNull_ImplicitTransaction() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -282,7 +282,7 @@ public async Task MaxCommitDelay_Propagates_ImplicitTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -306,7 +306,7 @@ public async Task MaxCommitDelay_SetOnCommand_SetOnExplicitTransaction_CommandIg SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -333,7 +333,7 @@ public async Task MaxCommitDelay_SetOnCommand_UnsetOnExplicitTransaction_Command SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -360,7 +360,7 @@ public async Task MaxCommitDelay_SetOnCommand_SetOnAmbientTransaction_CommandIgn SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -389,7 +389,7 @@ public async Task MaxCommitDelay_SetOnCommand_UnsetOnAmbientTransaction_CommandI SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); @@ -418,7 +418,7 @@ public async Task CommandIncludesRequestAndTransactionTag() var transactionTag = "transaction-tag-1"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerCommandTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerCommandTests.cs index b5b2885de5b2..c45b2f4eed09 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerCommandTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerCommandTests.cs @@ -121,7 +121,7 @@ public void CommandHasConnectionQueryOptions() const string connOptimizerStatisticsPackage = "stats_package_1"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -147,7 +147,7 @@ public void CommandHasQueryOptionsFromEnvironment() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); const string envOptimizerVersion = "2"; @@ -180,7 +180,7 @@ public void CommandHasQueryOptionsSetOnCommand() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); const string cmdOptimizerVersion = "3"; @@ -219,7 +219,7 @@ public void ExecuteSqlRequestHasDirectedReadOptionsSetOnCommand() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -241,7 +241,7 @@ public void ReadRequestHasDirectedReadOptionsSetOnCommand() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -263,12 +263,12 @@ public async Task PartitionHasDirectedReadOptionsSetOnCommand() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupPartitionAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); - var transaction = await connection.BeginTransactionAsync(SpannerTransactionCreationOptions.ReadOnly.WithIsDetached(true), transactionOptions: null, cancellationToken: default); + var transaction = await connection.BeginTransactionAsync(SpannerTransactionCreationOptions.ReadOnly, transactionOptions: null, cancellationToken: default); var command = connection.CreateReadCommand("Foo", ReadOptions.FromColumns("Col1", "Col2").WithLimit(10), KeySet.All); command.Transaction = transaction; var partitions = await command.GetReaderPartitionsAsync(PartitionOptions.Default.WithPartitionSizeBytes(0).WithMaxPartitions(10)); @@ -315,7 +315,7 @@ public async Task CommitPriorityDefaultsToUnspecified() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -340,7 +340,7 @@ public void CommandIncludesPriority() var priority = Priority.High; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -362,7 +362,7 @@ public async Task CommitIncludesPriority() var commandPriority = Priority.High; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -392,7 +392,7 @@ public async Task CommitPriorityCanBeSetAfterCommandExecution() var priority = Priority.Medium; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -419,7 +419,7 @@ public async Task PriorityCanBeSetToUnspecified() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -449,7 +449,7 @@ public async Task RunWithRetryableTransactionWithCommitPriority() var priority = Priority.Low; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync_Fails(1, StatusCode.Aborted, exceptionRetryDelay: TimeSpan.FromMilliseconds(0)) .SetupRollbackAsync(); @@ -477,7 +477,7 @@ public async Task MutationCommandIncludesPriority() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -496,7 +496,7 @@ public void PdmlCommandIncludesPriority() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.RowCountLowerBound); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -516,7 +516,7 @@ public async Task EphemeralTransactionIncludesPriorityOnDmlCommandAndCommit() var priority = Priority.Medium; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.RowCountExact) .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -551,7 +551,7 @@ public void CommandIncludesRequestTag() var tag = "tag-1"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -574,7 +574,7 @@ public async Task CommandIncludesRequestAndTransactionTag() var transactionTag = "transaction-tag-1"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -626,7 +626,7 @@ public async Task TransactionTagChangesIgnoredAfterCommandExecution() var transactionTag = "transaction-tag-1"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -656,7 +656,7 @@ public void TransactionTagIgnoredForReadOnlyTransaction() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction( @@ -680,7 +680,7 @@ public async Task TagsCanBeSetToNull() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -710,7 +710,7 @@ public async Task RunWithRetryableTransactionWithTransactionTag() var transactionTag = "retryable-tx-tag"; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync_Fails(1, StatusCode.Aborted, exceptionRetryDelay: TimeSpan.FromMilliseconds(0)) .SetupRollbackAsync(); @@ -769,7 +769,7 @@ public async Task MaxCommitDelay_DefaultsToNull_ExplicitTransaction() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -795,7 +795,7 @@ public async Task MaxCommitDelay_Propagates_ExplicitTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -822,7 +822,7 @@ public async Task MaxCommitDelay_CanBeSetAfterCommandExecution_ExplicitTransacti SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -849,7 +849,7 @@ public async Task MaxCommitDelay_Propagates_RunWithRetryableTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql() .SetupCommitAsync_Fails(1, StatusCode.Aborted, exceptionRetryDelay: TimeSpan.FromMilliseconds(0)) .SetupRollbackAsync(); @@ -875,7 +875,7 @@ public async Task MaxCommitDelay_DefaultsToNull_ImplicitTransaction() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -895,7 +895,7 @@ public async Task MaxCommitDelay_Propagates_ImplicitTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -917,7 +917,7 @@ public async Task MaxCommitDelay_SetOnCommand_SetOnExplicitTransaction_CommandIg SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -944,7 +944,7 @@ public async Task MaxCommitDelay_SetOnCommand_UnsetOnExplicitTransaction_Command SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -968,7 +968,7 @@ public async Task MaxCommitDelay_DefaultsToNull_AmbientTransaction() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -994,7 +994,7 @@ public async Task MaxCommitDelay_Propagates_AmbientTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -1021,7 +1021,7 @@ public async Task MaxCommitDelay_SetOnCommand_SetOnAmbientTransaction_CommandIgn SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -1048,7 +1048,7 @@ public async Task MaxCommitDelay_SetOnCommand_UnsetOnAmbientTransaction_CommandI SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -1083,7 +1083,7 @@ public async Task TransactionOptions_Propagates_ExplicitTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.None) .SetupCommitAsync(); @@ -1116,7 +1116,7 @@ public async Task TransactionOptions_Propagates_RunWithRetryableTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.None) .SetupCommitAsync(); @@ -1149,7 +1149,7 @@ public async Task TransactionOptions_Propagates_ImplicitTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.None) .SetupCommitAsync(); @@ -1179,7 +1179,7 @@ public async Task TransactionOptions_Propagates_AmbientTransaction() SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDml(ResultSetStats.RowCountOneofCase.None) .SetupCommitAsync(); @@ -1204,7 +1204,7 @@ public void ClientCreatedWithEmulatorDetection() { SpannerClient spannerClient = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClient - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); var sessionPoolOptions = new SessionPoolOptions @@ -1244,7 +1244,7 @@ public void ExecuteReaderHasResourceHeader() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -1264,7 +1264,7 @@ public void PdmlRetriedOnEosError() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupExecuteStreamingSqlForDmlThrowingEosError(); @@ -1283,7 +1283,7 @@ public async Task ParallelMutationCommandsOnAmbientTransaction_OnlyCreateOneSpan { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() .SetupCommitAsync(); @@ -1326,7 +1326,7 @@ public async Task CanExecuteReadCommand() var spannerClientMock = SpannerClientHelpers .CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1375,7 +1375,7 @@ public async Task CanExecuteReadAllCommand() var spannerClientMock = SpannerClientHelpers .CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1392,7 +1392,7 @@ public async Task CanExecuteReadCommandWithKeyRange() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1415,7 +1415,7 @@ public async Task CanExecuteReadCommandWithKeyCollection() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1439,7 +1439,7 @@ public async Task CanExecuteReadCommandWithIndex() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1456,7 +1456,7 @@ public async Task CanExecuteReadCommandWithLimit() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1477,7 +1477,7 @@ public async Task ExecuteReaderReadWithLockHint(LockHint? lockHintValue, ReadReq { var spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock.Received(1) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1505,7 +1505,7 @@ public async Task ExecuteReaderReadWithOrderBy(OrderBy? orderByValue, ReadReques { var spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock.Received(1) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(); var connection = BuildSpannerConnection(spannerClientMock); @@ -1527,7 +1527,7 @@ public async Task CanExecuteReadPartitionedReadCommand(bool dataBoostEnabled) { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupPartitionAsync() .SetupStreamingRead(); @@ -1627,7 +1627,7 @@ private Struct RunExecuteStreamingSqlWithParameter(SpannerConnectionStringBuilde var request = new ExecuteSqlRequest(); SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteStreamingSql(request); var connection = BuildSpannerConnection(spannerClientMock, builder); @@ -1648,7 +1648,7 @@ private ListValue RunReadRequest(SpannerConnectionStringBuilder builder, Spanner var request = new ReadRequest(); SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); spannerClientMock - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupStreamingRead(request); var connection = BuildSpannerConnection(spannerClientMock, builder); @@ -1686,7 +1686,9 @@ internal static SpannerConnection BuildSpannerConnection(SpannerClient spannerCl MaintenanceLoopDelay = TimeSpan.Zero }; - var sessionPoolManager = new SessionPoolManager(sessionPoolOptions, spannerClient.Settings, spannerClient.Settings.Logger, (_o, _s) => Task.FromResult(spannerClient)); + var managedSessionOptions = new ManagedSessionOptions(); + + var sessionPoolManager = new SessionPoolManager(managedSessionOptions, spannerClient.Settings, spannerClient.Settings.Logger, (_o, _s) => Task.FromResult(spannerClient)); sessionPoolManager.SpannerSettings.Scheduler = spannerClient.Settings.Scheduler; sessionPoolManager.SpannerSettings.Clock = spannerClient.Settings.Clock; diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.AbortedTransactionRetryTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.AbortedTransactionRetryTests.cs index 6d5d58329ec8..2ea374dbc06a 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.AbortedTransactionRetryTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.AbortedTransactionRetryTests.cs @@ -46,7 +46,7 @@ public sealed class TransactionAbortedRetryTests public async Task FirstCallSucceeds() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); @@ -69,7 +69,7 @@ await scheduler.RunAsync(async () => public async Task CommitAbortsTwice() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync_Fails(failures: 2, statusCode: StatusCode.Aborted) .SetupRollbackAsync(); @@ -95,7 +95,7 @@ await scheduler.RunAsync(async () => public async Task BatchDmlAbortsTwice() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupBeginTransactionAsync() // With transaction inlining we need batch DML to fail 4 times to get the retriable transaction // to abort twice. That is because on each run of the retriable transaction the batch DML command @@ -126,7 +126,7 @@ await scheduler.RunAsync(async () => public async Task CommitAbortsTwice_RecommendedDelay() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync_Fails(failures: 2, statusCode: StatusCode.Aborted, exceptionRetryDelay: TimeSpan.FromMilliseconds(ExceptionRetryDelayMs)) .SetupRollbackAsync(); @@ -151,7 +151,7 @@ await scheduler.RunAsync(async () => public async Task CommitAbortsAlways_RespectsOverallDeadline() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync_FailsAlways(statusCode: StatusCode.Aborted) .SetupRollbackAsync(); @@ -186,7 +186,7 @@ await scheduler.RunAsync(async () => public async Task CommitFailsOtherThanAborted() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync() + .SetupMultiplexSessionCreationAsync() .SetupExecuteBatchDmlAsync() .SetupCommitAsync_Fails(failures: 1, StatusCode.Unknown) .SetupRollbackAsync(); @@ -213,7 +213,7 @@ await scheduler.RunAsync(async () => public async Task WorkFails() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger) - .SetupBatchCreateSessionsAsync(); + .SetupMultiplexSessionCreationAsync(); SpannerConnection connection = BuildSpannerConnection(spannerClientMock); diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.cs index 7eeafa8a6970..9a4baa2fa0e0 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerConnectionTests.cs @@ -13,10 +13,8 @@ // limitations under the License. using Google.Apis.Auth.OAuth2; -using Google.Apis.Http; using Google.Cloud.Spanner.V1; using Google.Cloud.Spanner.V1.Internal.Logging; -using Grpc.Core; using Grpc.Auth; using System; using System.IO; @@ -33,7 +31,7 @@ public void OpenWithNoDatabase_InvalidCredentials() { var builder = new SpannerConnectionStringBuilder { - DataSource = "projects/project_id/instances/instance_id", + DataSource = "projects/project_id/instances/instance_id/databases/database_id", CredentialFile = "this_will_not_exist.json" }; using (var connection = new SpannerConnection(builder)) diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionCreationOptionsTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionCreationOptionsTests.cs index 342c878dd798..cd2a9d9b250d 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionCreationOptionsTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionCreationOptionsTests.cs @@ -13,8 +13,10 @@ // limitations under the License. using Google.Cloud.Spanner.V1; +using Google.Protobuf; using System; using Xunit; +using static Google.Api.Gax.Grpc.ClientHelper; using static Google.Cloud.Spanner.V1.TransactionOptions.Types; using IsolationLevel = System.Data.IsolationLevel; using SpannerIsolationLevel = Google.Cloud.Spanner.V1.TransactionOptions.Types.IsolationLevel; @@ -318,4 +320,40 @@ public void IsolationLevel_ConversionFailure(IsolationLevel clientIsolationLevel Assert.Throws(() => spannerTxnOptions.GetTransactionOptions()); } + + [Fact] + public void WithPreviousTransactionId_ReadWrite() + { + var options1 = SpannerTransactionCreationOptions.ReadWrite; + var optionsWithPrevTxnId = options1.WithPreviousTransactionId(ByteString.CopyFromUtf8("testId")); + + Assert.NotEqual(options1, optionsWithPrevTxnId); + } + + [Fact] + public void WithPreviousTransactionId_PartitionedDml() + { + var options1 = SpannerTransactionCreationOptions.PartitionedDml; + var optionsWithPrevTxnId = options1.WithPreviousTransactionId(ByteString.CopyFromUtf8("testId")); + + Assert.NotEqual(options1, optionsWithPrevTxnId); + } + + [Fact] + public void WithPreviousTransactionId_ReadOnly() + { + var options1 = SpannerTransactionCreationOptions.ReadOnly; + + Assert.Throws(() => options1.WithPreviousTransactionId(ByteString.CopyFromUtf8("testId"))); + } + + [Fact] + public void PreviousTransactionId_ToTransactionOptions() + { + ByteString prevTxnId = ByteString.CopyFromUtf8("testId"); + var optionsWithPrevTxnId = SpannerTransactionCreationOptions.ReadWrite.WithPreviousTransactionId(prevTxnId); + TransactionOptions spannerBackendOptions = optionsWithPrevTxnId.GetTransactionOptions(); + + Assert.Equal(prevTxnId, spannerBackendOptions.ReadWrite.MultiplexedSessionPreviousTransactionId); + } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionTests.cs index d5a60db1a5f5..5340459f4927 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/SpannerTransactionTests.cs @@ -40,7 +40,7 @@ public class SpannerTransactionTests public void MaxCommitDelay_DefaultsToNull() { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction(); @@ -51,7 +51,7 @@ public void MaxCommitDelay_DefaultsToNull() public void MaxCommitDelay_Valid(TimeSpan? maxCommitDelay) { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction(); @@ -63,7 +63,7 @@ public void MaxCommitDelay_Valid(TimeSpan? maxCommitDelay) public void MaxCommitDelay_Invalid(TimeSpan? maxCommitdelay) { SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction(); @@ -80,7 +80,7 @@ public void SpannerTransactionOptions_FromBeginTransaction() DisposeBehavior disposeBehavior = DisposeBehavior.CloseResources; SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); - spannerClientMock.SetupBatchCreateSessionsAsync(); + spannerClientMock.SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock); SpannerTransaction transaction = connection.BeginTransaction(SpannerTransactionCreationOptions.ReadWrite, new SpannerTransactionOptions { @@ -106,7 +106,7 @@ public void SpannerTransactionIsolationLevel_FromConnectionString() SpannerConnectionStringBuilder builder = new SpannerConnectionStringBuilder(); builder.IsolationLevel = System.Data.IsolationLevel.RepeatableRead; - SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger).SetupBatchCreateSessionsAsync(); + SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger).SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock, builder); SpannerTransaction transaction = connection.BeginTransaction(SpannerTransactionCreationOptions.ReadWrite, null); @@ -120,7 +120,7 @@ public void SpannerTransactionIsolationLevel_FromCreationOptions() SpannerConnectionStringBuilder builder = new SpannerConnectionStringBuilder(); builder.IsolationLevel = System.Data.IsolationLevel.RepeatableRead; - SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger).SetupBatchCreateSessionsAsync(); + SpannerClient spannerClientMock = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger).SetupMultiplexSessionCreationAsync(); SpannerConnection connection = SpannerCommandTests.BuildSpannerConnection(spannerClientMock, builder); SpannerTransaction transaction = connection.BeginTransaction(SpannerTransactionCreationOptions.ReadWrite.WithIsolationLevel(System.Data.IsolationLevel.Serializable), null); diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/DirectedReadTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/DirectedReadTests.cs index fbfbc8d0fe47..4a57332335c0 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/DirectedReadTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/DirectedReadTests.cs @@ -24,6 +24,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using static Google.Api.Gax.Grpc.ClientHelper; namespace Google.Cloud.Spanner.Data.Tests; @@ -61,6 +62,7 @@ public class DirectedReadTests private static readonly SessionName s_sessionName = SessionName.FromProjectInstanceDatabaseSession("project", "instance", "database", "session"); private static readonly ByteString s_transactionId = ByteString.CopyFromUtf8("transaction"); + private static readonly DatabaseName s_databaseName = DatabaseName.FromProjectInstanceDatabase("project", "instance", "database"); private static readonly TransactionOptions s_partitionedDml = new TransactionOptions { PartitionedDml = new TransactionOptions.Types.PartitionedDml() }; private static readonly TransactionOptions s_readWrite = new TransactionOptions { ReadWrite = new TransactionOptions.Types.ReadWrite() }; private static readonly TransactionOptions s_readOnly = new TransactionOptions { ReadOnly = new TransactionOptions.Types.ReadOnly() }; @@ -113,12 +115,10 @@ public async Task PooledSession_SetsOptionsFromClient_ExecuteSqlAsync(bool singl DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null); + await managedTransaction.ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -128,12 +128,10 @@ public async Task PooledSession_SetsOptionsFromRequest_ExecuteSqlAsync(bool sing { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, settings: null, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlAsync(new ExecuteSqlRequest + await managedTransaction.ExecuteSqlAsync(new ExecuteSqlRequest { DirectedReadOptions = IncludeDirectedReadOptions }, callSettings: null); @@ -152,12 +150,10 @@ public async Task PooledSession_RequestOptionsTakePrecedenceOverClientOptions_Ex DirectedReadOptions = ExcludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlAsync(new ExecuteSqlRequest + await managedTransaction.ExecuteSqlAsync(new ExecuteSqlRequest { DirectedReadOptions = IncludeDirectedReadOptions }, callSettings: null); @@ -180,13 +176,10 @@ public async Task PooledSession_NonReadOnlyTransaction_IgnoresOptionsFromClient_ DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - // Only read-only transaction can be single use. - .WithTransaction(s_transactionId, options, singleUseTransaction: false); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, options, singleUseTransaction: false); - await session.ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null); + await managedTransaction.ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null); Assert.Null(grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -201,12 +194,10 @@ public async Task PooledSession_SetsOptionsFromClient_ExecuteSqlStreamReader(boo DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlStreamReader(new ExecuteSqlRequest(), callSettings: null).HasDataAsync(default); + await (await managedTransaction.ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -216,15 +207,14 @@ public async Task PooledSession_SetsOptionsFromRequest_ExecuteSqlStreamReader(bo { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, settings: null, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlStreamReader(new ExecuteSqlRequest + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + + await (await managedTransaction.ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest { DirectedReadOptions = IncludeDirectedReadOptions - }, callSettings: null).HasDataAsync(default); + }, callSettings: null)).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -240,15 +230,13 @@ public async Task PooledSession_RequestOptionsTakePrecedenceOverClientOptions_Ex DirectedReadOptions = ExcludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ExecuteSqlStreamReader(new ExecuteSqlRequest + await (await managedTransaction.ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest { DirectedReadOptions = IncludeDirectedReadOptions - }, callSettings: null).HasDataAsync(default); + }, callSettings: null)).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -268,13 +256,10 @@ public async Task PooledSession_NonReadOnlyTransaction_IgnoresOptionsFromClient_ DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - // Only read-only transaction can be single use. - .WithTransaction(s_transactionId, options, singleUseTransaction: false); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, options, singleUseTransaction: false); - await session.ExecuteSqlStreamReader(new ExecuteSqlRequest(), callSettings: null).HasDataAsync(default); + await(await managedTransaction.ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).HasDataAsync(default); Assert.Null(grpcClient.LastExecuteSqlRequest.DirectedReadOptions); } @@ -289,12 +274,12 @@ public async Task PooledSession_SetsOptionsFromClient_ReadStreamReader(bool sing DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + + var reader = await managedTransaction.ReadStreamReaderAsync(new ReadRequest(), callSettings: null); + await reader.HasDataAsync(default); - await session.ReadStreamReader(new ReadRequest(), callSettings: null).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastReadRequest.DirectedReadOptions); } @@ -304,15 +289,14 @@ public async Task PooledSession_SetsOptionsFromRequest_ReadStreamReader(bool sin { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, settings: null, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ReadStreamReader(new ReadRequest + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + + await (await managedTransaction.ReadStreamReaderAsync(new ReadRequest { DirectedReadOptions = IncludeDirectedReadOptions - }, callSettings: null).HasDataAsync(default); + }, callSettings: null)).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastReadRequest.DirectedReadOptions); } @@ -328,15 +312,13 @@ public async Task PooledSession_RequestOptionsTakePrecedenceOverClientOptions_Re DirectedReadOptions = ExcludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - .WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, s_readOnly, singleUseTransaction); - await session.ReadStreamReader(new ReadRequest + await (await managedTransaction.ReadStreamReaderAsync(new ReadRequest { DirectedReadOptions = IncludeDirectedReadOptions - }, callSettings: null).HasDataAsync(default); + }, callSettings: null)).HasDataAsync(default); Assert.Equal(IncludeDirectedReadOptions, grpcClient.LastReadRequest.DirectedReadOptions); } @@ -356,16 +338,28 @@ public async Task PooledSession_NonReadOnlyTransaction_IgnoresOptionsFromClient_ DirectedReadOptions = IncludeDirectedReadOptions }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession - .FromSessionName(sessionPool, s_sessionName) - // Only read-only transaction can be single use. - .WithTransaction(s_transactionId, options, singleUseTransaction: false); + var managedTransaction = CreateManagedTransaction(spannerClient); + managedTransaction = managedTransaction.WithTransaction(s_transactionId, options, singleUseTransaction: false); - await session.ReadStreamReader(new ReadRequest(), callSettings: null).HasDataAsync(default); + await (await managedTransaction.ReadStreamReaderAsync(new ReadRequest(), callSettings: null)).HasDataAsync(default); Assert.Null(grpcClient.LastReadRequest.DirectedReadOptions); } + private ManagedTransaction CreateManagedTransaction(SpannerClient client) + { + var managedSession = new ManagedSession(client, s_databaseName, null, null); + managedSession.Session = new Session + { + CreateTime = Timestamp.FromDateTime(DateTime.UtcNow), + SessionName = SessionName.FromProjectInstanceDatabaseSession("projectId", "instanceId", "databaseId", "testSessionId"), + Multiplexed = true + }; + + var managedTransaction = new ManagedTransaction(managedSession, null, null, false, null); + + return managedTransaction; + } + public class FakeGrpcSpannerClient : V1.Spanner.SpannerClient { public ExecuteSqlRequest LastExecuteSqlRequest { get; private set; } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/ManagedSessionTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/ManagedSessionTests.cs new file mode 100644 index 000000000000..39394de0e480 --- /dev/null +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/ManagedSessionTests.cs @@ -0,0 +1,85 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Api.Gax.Testing; +using Google.Cloud.Spanner.Common.V1; +using Google.Cloud.Spanner.V1.Internal.Logging; +using System; +using System.Threading.Tasks; +using Xunit; +using static Google.Cloud.Spanner.V1.ManagedSession; + +namespace Google.Cloud.Spanner.V1.Tests; +public class ManagedSessionTests +{ + private const string TestDatabase = "projects/testproject/instances/testinstance/databases/testdb"; + + [Fact] + public async Task TestBuilderCreation() + { + ManagedSession multiplexSession = await FetchTestMultiplexSessionAsync(); + + Assert.NotNull(multiplexSession); + Assert.NotNull(multiplexSession.Session); + Assert.NotNull(multiplexSession.Client); + Assert.NotNull(multiplexSession.DatabaseName); + Assert.NotNull(multiplexSession.DatabaseRole); + } + + [Fact] + public async Task TestSessionHasExpired() + { + SpannerClient fakeClient = CreateFakeClient(); + ManagedSession multiplexSession = await FetchTestMultiplexSessionAsync(fakeClient); + + DateTime sessionCreateTime = multiplexSession.Session.CreateTime.ToDateTime(); + FakeClock clock = (FakeClock) fakeClient.Settings.Clock; + + clock.AdvanceTo(sessionCreateTime + TimeSpan.FromDays(3)); + Assert.True(multiplexSession.SessionHasExpired(2.0)); + + clock.AdvanceTo(sessionCreateTime + TimeSpan.FromDays(7)); + Assert.True(multiplexSession.SessionHasExpired()); + } + + private SpannerClient CreateFakeClient() + { + SpannerClient fakeClient = SpannerClientHelpers.CreateMockClient(Logger.DefaultLogger); + fakeClient.SetupMultiplexSessionCreationAsync(); + + return fakeClient; + } + + internal async Task FetchTestMultiplexSessionAsync(SpannerClient client = null) + { + if (!DatabaseName.TryParse(TestDatabase, out var databaseName)) + { + throw new Exception($"Unable to parse string to DatabaseName {TestDatabase}"); + } + + if (client == null) + { + client = CreateFakeClient(); + } + + SessionBuilder builder = new SessionBuilder(databaseName, client) + { + DatabaseRole = "testRole", + }; + + ManagedSession multiplexSession = await builder.BuildAsync(); + + return multiplexSession; + } +} diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/RouteToLeaderTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/RouteToLeaderTests.cs index 1e76fd06ed63..20c94d9ed769 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/RouteToLeaderTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/RouteToLeaderTests.cs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Google.Api.Gax; -using Google.Api.Gax.Testing; using Google.Cloud.ClientTesting; using Google.Cloud.Spanner.Common.V1; using Google.Cloud.Spanner.V1; @@ -122,66 +120,81 @@ public async Task SpannerClient_DoesNotRouteToLeaderWhenNotEnabled(Func header.Key == LeaderRoutingHeader && header.Value == true.ToString()); } - public static TheoryData> PooledSessionRoutesToLeader => new TheoryData> + public static TheoryData> ManagedTransactionRoutesToLeader => new TheoryData> { - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_partitionedDml, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readWrite, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_partitionedDml, false).ReadStreamReader(new ReadRequest(), callSettings: null).NextAsync(default) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readWrite, false).ReadStreamReader(new ReadRequest(), callSettings: null).NextAsync(default) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_partitionedDml, false).ExecuteSqlStreamReader(new ExecuteSqlRequest(), callSettings: null).NextAsync(default) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readWrite, false).ExecuteSqlStreamReader(new ExecuteSqlRequest(), callSettings: null).NextAsync(default) }, + { managedTransaction => managedTransaction.WithTransaction(s_transactionId, s_partitionedDml, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, + { managedTransaction => managedTransaction.WithTransaction(s_transactionId, s_readWrite, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_partitionedDml, false).ReadStreamReaderAsync(new ReadRequest(), callSettings: null)).NextAsync(default) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_readWrite, false).ReadStreamReaderAsync(new ReadRequest(), callSettings: null)).NextAsync(default) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_partitionedDml, false).ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).NextAsync(default) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_readWrite, false).ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).NextAsync(default) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_readWrite, false).ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).NextAsync(default) }, }; - public static TheoryData> PooledSessionDoesNotRouteToLeader => new TheoryData> + public static TheoryData> ManagedTransactionDoesNotRouteToLeader => new TheoryData> { - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readOnly, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readOnly, false).ReadStreamReader(new ReadRequest(), callSettings: null).NextAsync(default) }, - { pooledSession => pooledSession.WithTransaction(s_transactionId, s_readOnly, false).ExecuteSqlStreamReader(new ExecuteSqlRequest(), callSettings: null).NextAsync(default) }, + { managedTransaction => managedTransaction.WithTransaction(s_transactionId, s_readOnly, false).ExecuteSqlAsync(new ExecuteSqlRequest(), callSettings: null) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_readOnly, false).ReadStreamReaderAsync(new ReadRequest(), callSettings: null)).NextAsync(default) }, + { async managedTransaction => await (await managedTransaction.WithTransaction(s_transactionId, s_readOnly, false).ExecuteSqlStreamReaderAsync(new ExecuteSqlRequest(), callSettings: null)).NextAsync(default) }, }; [Theory] - [MemberData(nameof(PooledSessionRoutesToLeader))] - public async Task PooledSession_RoutesToLeaderWhenEnabled(Func operation) + [MemberData(nameof(ManagedTransactionRoutesToLeader))] + public async Task ManagedTransaction_RoutesToLeaderWhenEnabled(Func operation) { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, settings: null, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession.FromSessionName(sessionPool, s_sessionName); + var managedTransaction = CreateManagedTransaction(spannerClient); - await operation(session); + await operation(managedTransaction); Assert.Contains(grpcClient.LastCallOptions.Headers, header => header.Key == LeaderRoutingHeader && header.Value == true.ToString()); } + + [Theory] - [MemberData(nameof(PooledSessionDoesNotRouteToLeader))] - public async Task PooledSession_DoesNotRouteToLeaderWhenEnabled(Func operation) + [MemberData(nameof(ManagedTransactionDoesNotRouteToLeader))] + public async Task ManagedTransaction_DoesNotRouteToLeaderWhenEnabled(Func operation) { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, settings: null, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession.FromSessionName(sessionPool, s_sessionName); + var managedTransaction = CreateManagedTransaction(spannerClient); - await operation(session); + await operation(managedTransaction); Assert.DoesNotContain(grpcClient.LastCallOptions.Headers, header => header.Key == LeaderRoutingHeader && header.Value == true.ToString()); } [Theory] - [MemberData(nameof(PooledSessionRoutesToLeader))] - [MemberData(nameof(PooledSessionDoesNotRouteToLeader))] - public async Task PooledSession_DoesNotRouteToLeaderWhenNotEnabled(Func operation) + [MemberData(nameof(ManagedTransactionRoutesToLeader))] + [MemberData(nameof(ManagedTransactionDoesNotRouteToLeader))] + public async Task ManagedTransaction_DoesNotRouteToLeaderWhenNotEnabled(Func operation) { var grpcClient = new FakeGrpcSpannerClient(); var spannerClient = new SpannerClientImpl(grpcClient, new SpannerSettings { LeaderRoutingEnabled = false }, logger: null); - var sessionPool = new FakeSessionPool(spannerClient); - var session = PooledSession.FromSessionName(sessionPool, s_sessionName); + var managedTransaction = CreateManagedTransaction(spannerClient); - await operation(session); + await operation(managedTransaction); Assert.DoesNotContain(grpcClient.LastCallOptions.Headers, header => header.Key == LeaderRoutingHeader && header.Value == true.ToString()); } + private ManagedTransaction CreateManagedTransaction(SpannerClient client) + { + var managedSession = new ManagedSession(client, s_databaseName, null, null); + managedSession.Session = new Session + { + CreateTime = Timestamp.FromDateTime(DateTime.UtcNow), + SessionName = SessionName.FromProjectInstanceDatabaseSession("projectId", "instanceId", "databaseId", "testSessionId"), + Multiplexed = true + }; + + var managedTransaction = new ManagedTransaction(managedSession, null, null, false, null); + + return managedTransaction; + } + private class FakeGrpcSpannerClient : V1.Spanner.SpannerClient { public CallOptions LastCallOptions { get; private set; } @@ -252,22 +265,6 @@ private AsyncServerStreamingCall FakeAsyncServerStreamingCall( } } - private class FakeSessionPool : SessionPool.ISessionPool - { - public FakeSessionPool(SpannerClient spannerClient) => Client = spannerClient; - public SpannerClient Client { get; } - - public IClock Clock => new FakeClock(); - - public SessionPoolOptions Options => new SessionPoolOptions(); - - public bool TracksSessions => throw new NotImplementedException(); - - public void Detach(PooledSession session) => throw new NotImplementedException(); - public Task RefreshedOrNewAsync(PooledSession session, TransactionOptions transactionOptions, bool singleUseTransaction, CancellationToken cancellationToken) => throw new NotImplementedException(); - public void Release(PooledSession session, ByteString transactionToRollback, bool deleteSession) => throw new NotImplementedException(); - } - private class FakeAsyncStreamReader : IAsyncStreamReader { private bool _hasNext = true; diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SpannerClientHelpers.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SpannerClientHelpers.cs index 613b17d6c1e7..60106287984a 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SpannerClientHelpers.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SpannerClientHelpers.cs @@ -48,6 +48,7 @@ internal static class SpannerClientHelpers internal const string Instance = "dummy-instance"; internal const string Database = "dummy-database"; private static readonly string s_retryInfoMetadataKey = RetryInfo.Descriptor.FullName + "-bin"; + internal static readonly ByteString s_transactionId = ByteString.CopyFromUtf8("transaction"); /// /// Creates a mock SpannerClient configured with settings that include a fake clock @@ -65,6 +66,24 @@ internal static SpannerClient CreateMockClient(Logger logger) return mock; } + internal static SpannerClient SetupMultiplexSessionCreationAsync(this SpannerClient spannerClientMock) + { + spannerClientMock.Configure().CreateSessionAsync(Arg.Is(x => x != null), Arg.Any()) + .Returns(args => + { + var request = (CreateSessionRequest) args[0]; + Session response = new Session(); + response.CreateTime = spannerClientMock.GetNowTimestamp(); + response.CreatorRole = request.Session.CreatorRole; + response.Multiplexed = request.Session.Multiplexed; + response.Name = Guid.NewGuid().ToString(); + response.SessionName = new SessionName(ProjectId, Instance, Database, response.Name); + + return Task.FromResult(response); + }); + return spannerClientMock; + } + internal static SpannerClient SetupBatchCreateSessionsAsync(this SpannerClient spannerClientMock) { spannerClientMock.Configure() @@ -384,7 +403,7 @@ private static Timestamp GetNowTimestamp(this SpannerClient spannerClientMock) internal static Transaction MaybeGetTransaction(TransactionSelector selector) => selector?.SelectorCase switch { - TransactionSelector.SelectorOneofCase.Begin => new Transaction { Id = ByteString.CopyFromUtf8("transaction") }, + TransactionSelector.SelectorOneofCase.Begin => new Transaction { Id = s_transactionId }, _ => null }; } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SqlResultStreamTests.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SqlResultStreamTests.cs index 7ab21f800d5f..0ba8c02eb20b 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SqlResultStreamTests.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data.Tests/V1/SqlResultStreamTests.cs @@ -17,6 +17,7 @@ using Google.Api.Gax.Grpc.Testing; using Google.Api.Gax.Testing; using Google.Cloud.ClientTesting; +using Google.Cloud.Spanner.Common.V1; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Grpc.Core; @@ -314,13 +315,17 @@ private ResultStream CreateResultStream( int maxBufferSize = 10, CallSettings callSettings = null, RetrySettings retrySettings = null) - => new ResultStream( + { + ManagedSession managedSession = new ManagedSession(client, DatabaseName.FromProjectInstanceDatabase("projectId", "instanceId", "databaseId"), "testDatabaseRole", null); + ManagedTransaction transaction = new ManagedTransaction(managedSession, null, null, false, null); + return new ResultStream( client, ReadOrQueryRequest.FromRequest(type == typeof(ExecuteSqlRequest) ? new ExecuteSqlRequest() : new ReadRequest() as IReadOrQueryRequest), - PooledSession.FromSessionName(new PooledSessionTests.FakeSessionPool(), SessionName.FromProjectInstanceDatabaseSession("projectId", "instanceId", "databaseId", "sessionId")), + transaction, callSettings ?? s_simpleCallSettings, maxBufferSize, retrySettings ?? s_retrySettings); + } private static List CreateResultSets(params string[] resumeTokens) => resumeTokens diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/EphemeralTransaction.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/EphemeralTransaction.cs index c79172cb9225..c4f8a88c1987 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/EphemeralTransaction.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/EphemeralTransaction.cs @@ -163,6 +163,7 @@ async Task Impl(SpannerTransaction transaction) } } + // We don't need this method to be async anymore since we are not acquiring/creating any new session to execute this with multiplex session. Task ISpannerTransaction.ExecuteReadOrQueryAsync(ReadOrQueryRequest request, CancellationToken cancellationToken) { GaxPreconditions.CheckState(_creationOptions is null || _creationOptions.TimestampBound is not null, @@ -170,14 +171,17 @@ Task ISpannerTransaction.ExecuteReadOrQueryAsync(ReadOrQue return ExecuteHelper.WithErrorTranslationAndProfiling(Impl, "EphemeralTransaction.ExecuteReadOrQuery", _connection.Logger); - async Task Impl() + Task Impl() { - PooledSession session = await _connection.AcquireSessionAsync(_creationOptions, cancellationToken, out _).ConfigureAwait(false); + //PooledSession session = await _connection.AcquireSessionAsync(_creationOptions, cancellationToken, out _).ConfigureAwait(false); + ManagedTransaction transaction = _connection.AcquireManagedTransaction(_creationOptions, out _); var callSettings = _connection.CreateCallSettings( request.GetCallSettings, cancellationToken); - var reader = request.ExecuteReadOrQueryStreamReader(session, callSettings); - reader.StreamClosed += delegate { session.ReleaseToPool(forceDelete: false); }; + var reader = request.ExecuteReadOrQueryStreamReader(transaction, callSettings); + + // We don't need any cleanup wrt Session resources when stream closes + //reader.StreamClosed += delegate { session.ReleaseToPool(forceDelete: false); }; return reader; } } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/RetriableTransaction.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/RetriableTransaction.cs index 372e297945b6..f64b991f7dc2 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/RetriableTransaction.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/RetriableTransaction.cs @@ -14,6 +14,7 @@ using Google.Api.Gax; using Google.Cloud.Spanner.V1; +using Google.Protobuf; using System; using System.Threading; using System.Threading.Tasks; @@ -25,9 +26,10 @@ internal sealed class RetriableTransaction private readonly SpannerConnection _connection; private readonly IClock _clock; private readonly IScheduler _scheduler; - private readonly SpannerTransactionCreationOptions _creationOptions; + internal readonly SpannerTransactionCreationOptions _creationOptions; // internal for testing private readonly SpannerTransactionOptions _transactionOptions; private readonly RetriableTransactionOptions _retryOptions; + internal ByteString _prevTransactionId = ByteString.Empty; // initialize to empty ByteString to be in keeping with proto definition internal RetriableTransaction( SpannerConnection connection, @@ -41,7 +43,7 @@ internal RetriableTransaction( _scheduler = GaxPreconditions.CheckNotNull(scheduler, nameof(scheduler)); if (creationOptions is null) { - _creationOptions = SpannerTransactionCreationOptions.ReadWrite; + _creationOptions = SpannerTransactionCreationOptions.ReadWrite.WithPreviousTransactionId(); // Need to create a SpannerTransactionCreationOption with a null prev txn id to start } else { @@ -51,7 +53,7 @@ internal RetriableTransaction( && !creationOptions.IsPartitionedDml, nameof(creationOptions), "Retriable transactions must be read-write and may not be detached or partioned DML transactions."); - _creationOptions = creationOptions; + _creationOptions = creationOptions.WithPreviousTransactionId(); // Need to create a SpannerTransactionCreationOption with a null prev txn id to start } _transactionOptions = transactionOptions; _retryOptions = retryOptions ?? RetriableTransactionOptions.CreateDefault(); @@ -61,16 +63,10 @@ internal async Task RunAsync(Func CommitAttempt() { @@ -82,9 +78,10 @@ async Task CommitAttempt() try { SpannerTransactionCreationOptions effectiveCreationOptions = _creationOptions; - session = await (session?.RefreshedOrNewAsync(cancellationToken) ?? _connection.AcquireSessionAsync(_creationOptions, cancellationToken, out effectiveCreationOptions)).ConfigureAwait(false); - transaction = new SpannerTransaction(_connection, session, effectiveCreationOptions, _transactionOptions, isRetriable: true); + managedTransaction = _connection.AcquireManagedTransaction(_prevTransactionId != null ? _creationOptions.WithPreviousTransactionId(_prevTransactionId) : _creationOptions, out effectiveCreationOptions); + + transaction = new SpannerTransaction(_connection, managedTransaction, effectiveCreationOptions, _transactionOptions, isRetriable: true); TResult result = await asyncWork(transaction).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); @@ -107,6 +104,12 @@ async Task CommitAttempt() _connection.Logger.Warn("A rollback attempt failed on RetriableTransaction.RunAsync.CommitAttempt", e); } + // Update the transaction Id so that the next retry attempt can set this on the TransactionOptions + if(transaction.TransactionId?.Id != null) + { + _prevTransactionId = ByteString.FromBase64(transaction.TransactionId.Id); + } + // Throw, the retry helper will know when to retry. throw; } diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SessionPoolManager.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SessionPoolManager.cs index 93cb03c91b9b..8bb6f99890c9 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SessionPoolManager.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SessionPoolManager.cs @@ -13,6 +13,7 @@ // limitations under the License. using Google.Api.Gax; +using Google.Cloud.Spanner.Common.V1; using Google.Cloud.Spanner.V1; using Google.Cloud.Spanner.V1.Internal.Logging; using System; @@ -22,6 +23,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using static Google.Cloud.Spanner.V1.ManagedSession; using static Google.Cloud.Spanner.V1.SessionPool; namespace Google.Cloud.Spanner.Data @@ -47,7 +49,7 @@ static SessionPoolManager() /// is specified on construction. /// public static SessionPoolManager Default { get; } = - new SessionPoolManager(new SessionPoolOptions(), CreateDefaultSpannerSettings(), Logger.DefaultLogger, CreateClientAsync); + new SessionPoolManager(new ManagedSessionOptions(), CreateDefaultSpannerSettings(), Logger.DefaultLogger, CreateClientAsync); private readonly Func> _clientFactory; @@ -56,11 +58,19 @@ static SessionPoolManager() private readonly ConcurrentDictionary _poolReverseLookup = new ConcurrentDictionary(); + private readonly ConcurrentDictionary<(SpannerClientCreationOptions, SessionPoolSegmentKey), Task> _targetedMuxSessions = + new ConcurrentDictionary<(SpannerClientCreationOptions options, SessionPoolSegmentKey segmentKey), Task>(); + /// /// The session pool options used for every created by this session pool manager. /// public SessionPoolOptions SessionPoolOptions { get; } + /// + /// The options for every multiplex session created by this session pool manager. + /// + public ManagedSessionOptions MultiplexSessionOptions { get; } + /// /// The logger used by this SessionPoolManager and the session pools it creates. /// @@ -100,6 +110,18 @@ internal SessionPoolManager( _clientFactory = GaxPreconditions.CheckNotNull(clientFactory, nameof(clientFactory)); } + internal SessionPoolManager( + ManagedSessionOptions options, + SpannerSettings spannerSettings, + Logger logger, + Func> clientFactory) + { + MultiplexSessionOptions = GaxPreconditions.CheckNotNull(options, nameof(options)); + SpannerSettings = AppendAssemblyVersionHeader(GaxPreconditions.CheckNotNull(spannerSettings, nameof(spannerSettings))); + Logger = GaxPreconditions.CheckNotNull(logger, nameof(logger)); + _clientFactory = GaxPreconditions.CheckNotNull(clientFactory, nameof(clientFactory)); + } + /// /// Creates a with the specified options. /// @@ -109,6 +131,15 @@ internal SessionPoolManager( public static SessionPoolManager Create(SessionPoolOptions options, Logger logger = null) => new SessionPoolManager(options, CreateDefaultSpannerSettings(), logger ?? Logger.DefaultLogger, CreateClientAsync); + /// + /// + /// + /// + /// + /// + public static SessionPoolManager Create(ManagedSessionOptions options, Logger logger = null) => + new SessionPoolManager(options, CreateDefaultSpannerSettings(), logger ?? Logger.DefaultLogger, CreateClientAsync); + /// /// Creates a with the specified SpannerSettings and options. /// @@ -126,6 +157,32 @@ internal Task AcquireSessionPoolAsync(SpannerClientCreationOptions return targetedPool.SessionPoolTask; } + internal Task AcquireManagedSessionAsync(SpannerClientCreationOptions options, DatabaseName dbName, string dbRole) + { + SessionPoolSegmentKey segmentKey = SessionPoolSegmentKey.Create(dbName).WithDatabaseRole(dbRole); + GaxPreconditions.CheckNotNull(options, nameof(options)); + Logger.Warn($"Checking existance of mux in dictionary {segmentKey}, {_targetedMuxSessions.ContainsKey((options, segmentKey))}"); + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SPANNER_EMULATOR_HOST")) && _targetedMuxSessions.ContainsKey((options, segmentKey))) + { + _targetedMuxSessions[(options, segmentKey)] = CreateMultiplexSessionAsync(); + } + + var muxSession = _targetedMuxSessions.GetOrAdd((options, segmentKey), CreateMultiplexSessionAsync()); + return muxSession; + + async Task CreateMultiplexSessionAsync() + { + var client = await _clientFactory.Invoke(options, SpannerSettings).ConfigureAwait(false); + var muxSessionBuilder = new SessionBuilder(dbName, client) + { + Options = MultiplexSessionOptions, + DatabaseRole = dbRole, + }; + var muxSession = await muxSessionBuilder.BuildAsync().ConfigureAwait(false); + return muxSession; + } + } + /// /// Decrements the connection count associated with a client session pool. /// diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerCommand.ExecutableCommand.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerCommand.ExecutableCommand.cs index 00ea89c7d8ae..f9309e066905 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerCommand.ExecutableCommand.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerCommand.ExecutableCommand.cs @@ -147,8 +147,8 @@ internal async Task> GetReaderPartitionsAsync(Pa { ValidateConnectionAndCommandTextBuilder(); - GaxPreconditions.CheckState(Transaction?.Mode == TransactionMode.ReadOnly && Transaction?.IsDetached == true, - "GetReaderPartitions can only be executed within an explicitly created detached read-only transaction."); + GaxPreconditions.CheckState(Transaction?.Mode == TransactionMode.ReadOnly, + "GetReaderPartitions can only be executed within an explicitly created read-only transaction."); await Connection.EnsureIsOpenAsync(cancellationToken).ConfigureAwait(false); var readOrQueryRequest = GetReadOrQueryRequest(); diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnection.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnection.cs index 5db1758fcae7..86b4208fd91c 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnection.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnection.cs @@ -28,6 +28,7 @@ using System.Threading; using System.Threading.Tasks; using static Google.Cloud.Spanner.V1.SessionPool; +using static Google.Cloud.Spanner.V1.TransactionOptions; using Transaction = System.Transactions.Transaction; namespace Google.Cloud.Spanner.Data @@ -53,7 +54,11 @@ public sealed class SpannerConnection : DbConnection // The SessionPool to use to allocate sessions. This is obtained from the SessionPoolManager, // and released when the connection is closed/disposed. - private SessionPool _sessionPool; + //private SessionPool _sessionPool; + + // A managed (or multiplex) session which will be used as the underlying session for + // any transaction executed through this connection + private ManagedSession _managedSession; private ConnectionState _state = ConnectionState.Closed; @@ -338,7 +343,7 @@ private void Open(Action transactionEnlister) } /// - /// Opens the connection, which involves acquiring a SessionPool, + /// Opens the connection, which involves acquiring a ManagedSession, /// and potentially enlists the connection in the current transaction. /// /// Enlistment delegate; may be null. @@ -359,17 +364,21 @@ private Task OpenAsyncImpl(Action transactionEnlister, CancellationToken cancell return; } - if (previousState == ConnectionState.Connecting) - { - throw new InvalidOperationException("The SpannerConnection is already being opened."); - } + // Purva: Acquiring a mux session can take more time than just acquiring a SessionPool due to the actual creation of the mux session in the background + // If the connection is still connecting that means a previous 'Open' request has kicked off Mux Session creation. + // We can count on the Builder to make sure we create just 1 Mux Session for any client options + // So we just wait on the Mux Session creation below instead of throwing an exception + //if (previousState == ConnectionState.Connecting) + //{ + // throw new InvalidOperationException("The SpannerConnection is already being opened."); + //} _state = ConnectionState.Connecting; } OnStateChange(new StateChangeEventArgs(previousState, ConnectionState.Connecting)); try { - _sessionPool = await Builder.AcquireSessionPoolAsync().ConfigureAwait(false); + _managedSession = await Builder.AcquireManagedSessionAsync().ConfigureAwait(false); } finally { @@ -377,7 +386,7 @@ private Task OpenAsyncImpl(Action transactionEnlister, CancellationToken cancell // but it's not clear whether or not that's a problem. lock (_sync) { - _state = _sessionPool != null ? ConnectionState.Open : ConnectionState.Broken; + _state = _managedSession != null ? ConnectionState.Open : ConnectionState.Broken; } if (IsOpen) { @@ -580,20 +589,20 @@ internal Task BeginTransactionAsyncImpl( { await OpenAsync(cancellationToken).ConfigureAwait(false); - PooledSession session; - SpannerTransactionCreationOptions effectiveCreationOptions; - if (transactionCreationOptions.TransactionId is null) - { - session = await AcquireSessionAsync(transactionCreationOptions, cancellationToken, out effectiveCreationOptions).ConfigureAwait(false); - } - else - { - SessionName sessionName = SessionName.Parse(transactionCreationOptions.TransactionId.Session); - ByteString transactionIdBytes = ByteString.FromBase64(transactionCreationOptions.TransactionId.Id); - session = _sessionPool.CreateDetachedSession(sessionName, transactionIdBytes, TransactionOptions.ModeOneofCase.ReadOnly); - effectiveCreationOptions = transactionCreationOptions; - } - return new SpannerTransaction(this, session, effectiveCreationOptions, transactionOptions, isRetriable: false); + SpannerTransactionCreationOptions effectiveCreationOptions = transactionCreationOptions; + ManagedTransaction transaction = transactionCreationOptions.TransactionId is null ? + AcquireManagedTransaction(transactionCreationOptions, out effectiveCreationOptions) + : AcquireManagedTransactionWithMode(ByteString.FromBase64(effectiveCreationOptions.TransactionId.Id), ModeOneofCase.ReadOnly); + //ByteString transactionIdBytes = null; + //SpannerTransactionCreationOptions effectiveCreationOptions = MaybeWithConnectionDefaults(transactionCreationOptions); + + //if (effectiveCreationOptions.TransactionId is not null) + //{ + // transactionIdBytes = ByteString.FromBase64(effectiveCreationOptions.TransactionId.Id); + //} + + //transaction = new ManagedTransaction(_managedSession, transactionIdBytes, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions.IsSingleUse == true, null); + return new SpannerTransaction(this, transaction, effectiveCreationOptions, transactionOptions, isRetriable: false); }, "SpannerConnection.BeginTransactionAsync", Logger); } @@ -998,33 +1007,81 @@ public SpannerCommand CreateDmlCommand(string dmlStatement, SpannerParameterColl /// /// An optional token for canceling the call. /// A task which will complete when the session pool has reached its minimum size. + // TODO: Deprecate this method public async Task WhenSessionPoolReady(CancellationToken cancellationToken = default) { var sessionPoolSegmentKey = GetSessionPoolSegmentKey(nameof(WhenSessionPoolReady)); await OpenAsync(cancellationToken).ConfigureAwait(false); - await _sessionPool.WhenPoolReady(sessionPoolSegmentKey, cancellationToken).ConfigureAwait(false); + //await _sessionPool.WhenPoolReady(sessionPoolSegmentKey, cancellationToken).ConfigureAwait(false); } - internal Task AcquireSessionAsync(SpannerTransactionCreationOptions creationOptions, CancellationToken cancellationToken, out SpannerTransactionCreationOptions effectiveCreationOptions) + // TODO: Deprecate this method + //internal Task AcquireSessionAsync(SpannerTransactionCreationOptions creationOptions, CancellationToken cancellationToken, out SpannerTransactionCreationOptions effectiveCreationOptions) + //{ + // SessionPool pool; + // DatabaseName databaseName; + // effectiveCreationOptions = MaybeWithConnectionDefaults(creationOptions); + + // lock (_sync) + // { + // AssertOpen("acquire session."); + // pool = _sessionPool; + // databaseName = Builder.DatabaseName; + // } + // if (databaseName is null) + // { + // throw new InvalidOperationException("Unable to acquire session on connection with no database name"); + // } + // var sessionPoolSegmentKey = GetSessionPoolSegmentKey(nameof(AcquireSessionAsync)); + // return effectiveCreationOptions?.IsDetached == true ? + // pool.AcquireDetachedSessionAsync(sessionPoolSegmentKey, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions?.IsSingleUse == true, cancellationToken) : + // pool.AcquireSessionAsync(sessionPoolSegmentKey, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions?.IsSingleUse == true, cancellationToken); + //} + + internal ManagedTransaction AcquireManagedTransactionWithMode(ByteString transactionId, ModeOneofCase transactionMode) { - SessionPool pool; - DatabaseName databaseName; - effectiveCreationOptions = MaybeWithConnectionDefaults(creationOptions); + ManagedTransaction transaction = new ManagedTransaction(_managedSession, transactionId, BuildTransactionOptions(), false, null); + + TransactionOptions BuildTransactionOptions() => transactionMode switch + { + ModeOneofCase.None => new TransactionOptions(), + ModeOneofCase.PartitionedDml => new TransactionOptions { PartitionedDml = new() }, + ModeOneofCase.ReadWrite => new TransactionOptions { ReadWrite = new() }, + ModeOneofCase.ReadOnly => new TransactionOptions() { ReadOnly = new() }, + _ => throw new ArgumentException(nameof(transactionMode), $"Unknown {typeof(ModeOneofCase).FullName}: {transactionMode}") + }; + + return transaction; + } + + // Purva: Should this be public to mimic the SessionPool.AcquireSession ? + internal ManagedTransaction AcquireManagedTransaction(SpannerTransactionCreationOptions creationOptions, out SpannerTransactionCreationOptions effectiveCreationOptions) + { + ManagedSession session; lock (_sync) { AssertOpen("acquire session."); - pool = _sessionPool; - databaseName = Builder.DatabaseName; + session = _managedSession; + } + + if (Builder.DatabaseName is null) + { + // Ideally we should never reach here as the DatabaseName is essential to create the ManagedSession itself. + throw new InvalidOperationException("Unable to acquire a transaction on connection with no database name"); } - if (databaseName is null) + + ByteString transactionIdBytes = null; + effectiveCreationOptions = MaybeWithConnectionDefaults(creationOptions); + + if (effectiveCreationOptions?.TransactionId is not null) { - throw new InvalidOperationException("Unable to acquire session on connection with no database name"); + // If we already have a transaction, we need to create a ManagedTransaction object around this transactionId. + + transactionIdBytes = ByteString.FromBase64(effectiveCreationOptions.TransactionId.Id); } - var sessionPoolSegmentKey = GetSessionPoolSegmentKey(nameof(AcquireSessionAsync)); - return effectiveCreationOptions?.IsDetached == true ? - pool.AcquireDetachedSessionAsync(sessionPoolSegmentKey, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions?.IsSingleUse == true, cancellationToken) : - pool.AcquireSessionAsync(sessionPoolSegmentKey, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions?.IsSingleUse == true, cancellationToken); + + return new ManagedTransaction(_managedSession, transactionIdBytes, effectiveCreationOptions?.GetTransactionOptions(), effectiveCreationOptions?.IsSingleUse == true, null); } private SpannerTransactionCreationOptions MaybeWithConnectionDefaults(SpannerTransactionCreationOptions transactionCreationOptions) @@ -1052,11 +1109,12 @@ private SpannerTransactionCreationOptions MaybeWithConnectionDefaults(SpannerTra /// /// An optional token for canceling the returned task. This does not cancel the shutdown itself. /// A task which will complete when the session pool has finished shutting down. + // TODO: Deprecate this method public async Task ShutdownSessionPoolAsync(CancellationToken cancellationToken = default) { var sessionPoolSegmentKey = GetSessionPoolSegmentKey(nameof(ShutdownSessionPoolAsync)); await OpenAsync(cancellationToken).ConfigureAwait(false); - await _sessionPool.ShutdownPoolAsync(sessionPoolSegmentKey, cancellationToken).ConfigureAwait(false); + //await _sessionPool.ShutdownPoolAsync(sessionPoolSegmentKey, cancellationToken).ConfigureAwait(false); } /// @@ -1065,6 +1123,7 @@ public async Task ShutdownSessionPoolAsync(CancellationToken cancellationToken = /// /// The session pool statistics, or null if there is no current session pool /// associated with the . + // TODO: Deprecate this method public SessionPool.SessionPoolSegmentStatistics GetSessionPoolSegmentStatistics() { var sessionPoolSegmentKey = GetSessionPoolSegmentKey(nameof(GetSessionPoolSegmentStatistics)); @@ -1099,7 +1158,7 @@ private void TrySetNewConnectionInfo(SpannerConnectionStringBuilder newBuilder) /// public override void Close() { - SessionPool sessionPool; + //SessionPool sessionPool; ConnectionState oldState; lock (_sync) @@ -1109,20 +1168,21 @@ public override void Close() return; } + // We do not need any special cleanup for Multiplex Sessions oldState = _state; - sessionPool = _sessionPool; + //sessionPool = _sessionPool; - _sessionPool = null; + //_sessionPool = null; _state = ConnectionState.Closed; } - if (sessionPool != null) - { - // Note: if we're in an implicit transaction using TransactionScope, this will "release" the session pool - // back to the session pool manager before we're really done with it, but that's okay - it will just report - // inaccurate connection counts temporarily. This is an inherent problem with implicit transactions. - Builder.SessionPoolManager.Release(sessionPool); - } + //if (sessionPool != null) + //{ + // // Note: if we're in an implicit transaction using TransactionScope, this will "release" the session pool + // // back to the session pool manager before we're really done with it, but that's okay - it will just report + // // inaccurate connection counts temporarily. This is an inherent problem with implicit transactions. + // Builder.SessionPoolManager.Release(sessionPool); + //} if (oldState != _state) { diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnectionStringBuilder.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnectionStringBuilder.cs index 1f167953a44d..f163cb9b9efa 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnectionStringBuilder.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerConnectionStringBuilder.cs @@ -524,6 +524,9 @@ public SessionPoolManager SessionPoolManager internal Task AcquireSessionPoolAsync() => SessionPoolManager.AcquireSessionPoolAsync(new SpannerClientCreationOptions(this)); + internal Task AcquireManagedSessionAsync() => + SessionPoolManager.AcquireManagedSessionAsync(new SpannerClientCreationOptions(this), DatabaseName, DatabaseRole); + /// /// Copy constructor, used for cloning. (This allows for the use of object initializers, unlike /// the method.) diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransaction.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransaction.cs index e47a12fdc9c4..7d1829644350 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransaction.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransaction.cs @@ -59,7 +59,7 @@ private protected SpannerTransactionBase() public sealed class SpannerTransaction : SpannerTransactionBase, ISpannerTransaction { private readonly List _mutations = new List(); - private readonly SpannerTransactionCreationOptions _creationOptions; + internal readonly SpannerTransactionCreationOptions _creationOptions; // internal for testing // This value will be true if and only if this transaction was created by RetriableTransaction. private readonly bool _isRetriable = false; private int _disposed = 0; @@ -104,7 +104,9 @@ public sealed class SpannerTransaction : SpannerTransactionBase, ISpannerTransac /// public TransactionMode Mode => _creationOptions.TransactionMode; - private readonly PooledSession _session; + //private readonly PooledSession _session; + + internal readonly ManagedTransaction _transaction; // internal for testing /// /// Options to apply to the transaction after creation, usually before committing the transaction @@ -203,6 +205,7 @@ public string Tag } } + // TODO; Deprecate this internal SpannerTransaction( SpannerConnection connection, PooledSession session, @@ -211,18 +214,33 @@ internal SpannerTransaction( bool isRetriable) { SpannerConnection = GaxPreconditions.CheckNotNull(connection, nameof(connection)); - _session = GaxPreconditions.CheckNotNull(session, nameof(session)); + //_session = GaxPreconditions.CheckNotNull(session, nameof(session)); _creationOptions = GaxPreconditions.CheckNotNull(creationOptions, nameof(creationOptions)); TransactionOptions = transactionOptions is null ? new SpannerTransactionOptions() : new SpannerTransactionOptions(transactionOptions); _isRetriable = isRetriable; } - /// + internal SpannerTransaction( + SpannerConnection connection, + ManagedTransaction transaction, + SpannerTransactionCreationOptions creationOptions, + SpannerTransactionOptions transactionOptions, + bool isRetriable) + { + SpannerConnection = GaxPreconditions.CheckNotNull(connection, nameof(connection)); + _transaction = GaxPreconditions.CheckNotNull(transaction, nameof(transaction)); + _creationOptions = GaxPreconditions.CheckNotNull(creationOptions, nameof(creationOptions)); + TransactionOptions = transactionOptions is null ? new SpannerTransactionOptions() : new SpannerTransactionOptions(transactionOptions); + _isRetriable = isRetriable; + } + + /*/// /// Whether this transaction is detached or not. /// A detached transaction's resources are not pooled, so the transaction may be /// shared across processes for instance, for partitioned reads. /// - public bool IsDetached => _session.IsDetached; + // TODO: Deprecate this with Multiplex Session + //public bool IsDetached => _session.IsDetached;*/ /// /// Specifies how resources are treated when is called. @@ -275,7 +293,7 @@ internal Task> GetPartitionTokensAsync( CheckNotDisposed(); GaxPreconditions.CheckNotNull(request, nameof(request)); GaxPreconditions.CheckState(Mode == TransactionMode.ReadOnly, "You can only call GetPartitions on a read-only transaction."); - GaxPreconditions.CheckState(IsDetached, "You can only call GetPartitions on a detached transaction."); + //GaxPreconditions.CheckState(IsDetached, "You can only call GetPartitions on a detached transaction."); _hasExecutedStatements = true; ApplyTransactionTag(request); @@ -286,7 +304,7 @@ internal Task> GetPartitionTokensAsync( var callSettings = SpannerConnection.CreateCallSettings( partitionRequest.GetCallSettings, timeoutSeconds, cancellationToken); - var response = await partitionRequest.PartitionReadOrQueryAsync(_session, callSettings).ConfigureAwait(false); + var response = await partitionRequest.PartitionReadOrQueryAsync(_transaction, callSettings).ConfigureAwait(false); return response.Partitions.Select(x => x.PartitionToken); }, "SpannerTransaction.GetPartitionTokensAsync", SpannerConnection.Logger); @@ -328,7 +346,7 @@ Task ISpannerTransaction.ExecuteReadOrQueryAsync( var callSettings = SpannerConnection.CreateCallSettings( request.GetCallSettings, cancellationToken); - return Task.FromResult(request.ExecuteReadOrQueryStreamReader(_session, callSettings)); + return request.ExecuteReadOrQueryStreamReader(_transaction, callSettings); } Task ISpannerTransaction.ExecuteDmlAsync(ExecuteSqlRequest request, CancellationToken cancellationToken, int timeoutSeconds) @@ -344,7 +362,7 @@ Task ISpannerTransaction.ExecuteDmlAsync(ExecuteSqlRequest request, Cancel // Note: ExecuteSql would work, but by using a streaming call we enable potential future scenarios // where the server returns interim resume tokens to avoid timeouts. var callSettings = SpannerConnection.CreateCallSettings(settings => settings.ExecuteStreamingSqlSettings, timeoutSeconds, cancellationToken); - using (var reader = _session.ExecuteSqlStreamReader(request, callSettings)) + using (var reader = await _transaction.ExecuteSqlStreamReaderAsync(request, callSettings).ConfigureAwait(false)) { await reader.NextAsync(cancellationToken).ConfigureAwait(false); var stats = reader.Stats; @@ -377,7 +395,7 @@ Task ISpannerTransaction.ExecuteDmlReaderAsync(ExecuteSqlR return ExecuteHelper.WithErrorTranslationAndProfiling(async () => { var callSettings = SpannerConnection.CreateCallSettings(settings => settings.ExecuteStreamingSqlSettings, timeoutSeconds, cancellationToken); - using var reader = _session.ExecuteSqlStreamReader(request, callSettings); + using var reader = await _transaction.ExecuteSqlStreamReaderAsync(request, callSettings).ConfigureAwait(false); await reader.EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); return reader; }, "SpannerTransaction.ExecuteDmlReader", SpannerConnection.Logger); @@ -394,7 +412,7 @@ Task> ISpannerTransaction.ExecuteBatchDmlAsync(ExecuteBatchDml return ExecuteHelper.WithErrorTranslationAndProfiling(async () => { var callSettings = SpannerConnection.CreateCallSettings(settings => settings.ExecuteBatchDmlSettings, timeoutSeconds, cancellationToken); - ExecuteBatchDmlResponse response = await _session.ExecuteBatchDmlAsync(request, callSettings).ConfigureAwait(false); + ExecuteBatchDmlResponse response = await _transaction.ExecuteBatchDmlAsync(request, callSettings).ConfigureAwait(false); IEnumerable result = response.ResultSets.Select(rs => rs.Stats.RowCountExact); // Work around an issue with the emulator, which can return an ExecuteBatchDmlResponse without populating a status. // TODO: Remove this when the emulator has been fixed, although it does no harm if it stays longer than strictly necessary. @@ -446,7 +464,7 @@ Task> ISpannerTransaction.ExecuteBatchDmlAsync(ExecuteBatchDml { var callSettings = SpannerConnection.CreateCallSettings( settings => settings.CommitSettings, TransactionOptions.EffectiveCommitTimeout(SpannerConnection), cancellationToken); - var response = await _session.CommitAsync(request, callSettings).ConfigureAwait(false); + var response = await _transaction.CommitAsync(request, callSettings).ConfigureAwait(false); Interlocked.Exchange(ref _commited, 1); // We dispose of the SpannerTransaction to inmediately release the session to the pool when possible. Dispose(); @@ -501,7 +519,7 @@ public override async Task RollbackAsync(CancellationToken cancellationToken = d var callSettings = SpannerConnection.CreateCallSettings( settings => settings.RollbackSettings, TransactionOptions.EffectiveCommitTimeout(SpannerConnection), cancellationToken); await ExecuteHelper.WithErrorTranslationAndProfiling( - () => _session.RollbackAsync(new RollbackRequest(), callSettings), + () => _transaction.RollbackAsync(new RollbackRequest(), callSettings), "SpannerTransaction.Rollback", SpannerConnection.Logger).ConfigureAwait(false); Dispose(); } @@ -511,15 +529,15 @@ await ExecuteHelper.WithErrorTranslationAndProfiling( /// public TransactionId TransactionId => new TransactionId( SpannerConnection.ConnectionString, - _session.SessionName.ToString(), - _session.TransactionId?.ToBase64(), + _transaction.SessionName.ToString(), + _transaction.TransactionId?.ToBase64(), TimestampBound); /// /// The read timestamp of the read-only transaction if /// is true, else null. /// - public Timestamp ReadTimestamp => _session.ReadTimestamp; + public Timestamp ReadTimestamp => _transaction.ReadTimestamp; private void CheckNotDisposed() { @@ -549,20 +567,21 @@ protected override void Dispose(bool disposing) return; } - switch (TransactionOptions.DisposeBehavior) - { - case DisposeBehavior.CloseResources: - _session.ReleaseToPool(forceDelete: true); - break; - case DisposeBehavior.Default: - // This is a no-op for a detached session. - // We don't have to make a distinction here. - _session.ReleaseToPool(forceDelete: false); - break; - default: - // Default for unknown DisposeBehavior is to do nothing. - break; - } + // We don't need to handle and special resource disposing with Multiplex Sessions + //switch (TransactionOptions.DisposeBehavior) + //{ + // case DisposeBehavior.CloseResources: + // _session.ReleaseToPool(forceDelete: true); + // break; + // case DisposeBehavior.Default: + // // This is a no-op for a detached session. + // // We don't have to make a distinction here. + // _session.ReleaseToPool(forceDelete: false); + // break; + // default: + // // Default for unknown DisposeBehavior is to do nothing. + // break; + //} } private void CheckCompatibleMode(TransactionMode mode) diff --git a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransactionCreationOptions.cs b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransactionCreationOptions.cs index 530bd0e7ff79..5c755ab78017 100644 --- a/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransactionCreationOptions.cs +++ b/apis/Google.Cloud.Spanner.Data/Google.Cloud.Spanner.Data/SpannerTransactionCreationOptions.cs @@ -14,6 +14,8 @@ using Google.Api.Gax; using Google.Cloud.Spanner.V1; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using System; using static Google.Cloud.Spanner.V1.TransactionOptions.Types; using IsolationLevel = System.Data.IsolationLevel; @@ -120,12 +122,17 @@ public sealed class SpannerTransactionCreationOptions /// public ReadLockMode ReadLockMode { get; } + /// + /// This will only be set by RetriableTransaction and is needed for Multiplex Sessions to work correctly during retries. + /// + internal ByteString PreviousTransactionId { get; set; } + private SpannerTransactionCreationOptions(TimestampBound timestampBound, TransactionId transactionId, bool isDetached, bool isSingleUse, bool isPartitionedDml, bool excludeFromChangeStreams, IsolationLevel isolationLevel, ReadLockMode readLockMode) { GaxPreconditions.CheckArgument( - timestampBound is null || transactionId is null, - nameof(timestampBound), - $"At most one of {nameof(timestampBound)} and {nameof(transactionId)} may be set."); + timestampBound is null || transactionId is null, + nameof(timestampBound), + $"At most one of {nameof(timestampBound)} and {nameof(transactionId)} may be set."); GaxPreconditions.CheckArgument( transactionId is null || isDetached, nameof(isDetached), @@ -217,6 +224,10 @@ internal TransactionOptions GetTransactionOptions() if(options.ReadWrite is not null) { options.ReadWrite.ReadLockMode = ReadLockModeConverter.ToProto(ReadLockMode); + if(PreviousTransactionId != null) + { + options.ReadWrite.MultiplexedSessionPreviousTransactionId = PreviousTransactionId; + } } } @@ -269,6 +280,17 @@ public SpannerTransactionCreationOptions WithIsolationLevel(IsolationLevel isola /// public SpannerTransactionCreationOptions WithReadLockMode(ReadLockMode readLockMode) => readLockMode == ReadLockMode ? this : new SpannerTransactionCreationOptions(TimestampBound, TransactionId, IsDetached, IsSingleUse, IsPartitionedDml, ExcludeFromChangeStreams, IsolationLevel, readLockMode); + + internal SpannerTransactionCreationOptions WithPreviousTransactionId(ByteString previousTransactionId = null) + { + GaxPreconditions.CheckState( + TransactionMode == TransactionMode.ReadWrite, + "PreviousTransactionId is only settable on a ReadWrite transaction"); + + var options = new SpannerTransactionCreationOptions(TimestampBound, TransactionId, IsDetached, IsSingleUse, IsPartitionedDml, ExcludeFromChangeStreams, IsolationLevel, ReadLockMode); + options.PreviousTransactionId = previousTransactionId; + return options; + } } /// diff --git a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSession.cs b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSession.cs new file mode 100644 index 000000000000..3b8c1fd425ed --- /dev/null +++ b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSession.cs @@ -0,0 +1,272 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Api.Gax; +using Google.Api.Gax.Grpc; +using Google.Cloud.Spanner.Common.V1; +using Google.Cloud.Spanner.V1.Internal; +using Google.Cloud.Spanner.V1.Internal.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Google.Cloud.Spanner.V1; + +/// +/// TODO: Add summary for mux sessions +/// +public class ManagedSession +{ + private readonly SemaphoreSlim _sessionCreateSemaphore; + private readonly Logger _logger; + private readonly CreateSessionRequest _createSessionRequestTemplate; + + internal Session _session; + internal int _markedForRefresh; + + private const double ForceRefreshIntervalInDays = 28.0; + private const double SoftRefreshIntervalInDays = 7.0; + + private readonly IClock _clock; + + /// + /// The client used for all operations in this multiplex session. + /// + internal SpannerClient Client { get; } + + internal Task CreateSessionTask { get; } + + /// + /// The name of the session. This is never null. + /// + public SessionName SessionName => Session.SessionName; + + /// + /// The Spanner session resource associated to this pooled session. + /// Won't be null. + /// + internal Session Session + { + get { return _session; } + set { _session = value; } + } + + private bool MarkedForRefresh => Interlocked.CompareExchange(ref _markedForRefresh, 0, 0) == 1; + + /// + /// The options governing this multiplex session. + /// + public ManagedSessionOptions Options { get; } + + /// + /// The database for this multiplex session + /// + public DatabaseName DatabaseName { get; } + + /// + /// The database role of the multiplex session + /// + public string DatabaseRole { get; } + + /// + /// + /// + /// + /// + /// + /// + public ManagedSession(SpannerClient client, DatabaseName dbName, string dbRole, ManagedSessionOptions options) + { + Client = GaxPreconditions.CheckNotNull(client, nameof(client)); + Options = options ?? new ManagedSessionOptions(); + _logger = client.Settings.Logger; // Just to avoid fetching it all the time + _sessionCreateSemaphore = new SemaphoreSlim(1); + + DatabaseName = dbName; + DatabaseRole = dbRole; + + _clock = client.Settings.Clock ?? SystemClock.Instance; + + _createSessionRequestTemplate = new CreateSessionRequest + { + DatabaseAsDatabaseName = DatabaseName, + Session = new Session + { + CreatorRole = DatabaseRole ?? "", + Multiplexed = true + } + }; + } + + private async Task UpdateMuxSession(bool needsRefresh, double intervalInDays) + { + Session oldSession = _session; + await CreateOrRefreshSessionsAsync(default).ConfigureAwait(false); + + return _session != oldSession; + } + + internal async Task MaybeRefreshWithTimePeriodCheck() + { + if (SessionHasExpired(ForceRefreshIntervalInDays)) + { + // If the session has expired on a client RPC request call, or has exceeded the 28 day Mux session refresh guidance + // No request can proceed without us having a new Session to work with + // Block on refreshing and getting a new session + bool sessionIsRefreshed = await UpdateMuxSession(true, ForceRefreshIntervalInDays).ConfigureAwait(false); + + if (!sessionIsRefreshed) + { + throw new Exception("Unable to refresh multiplex session, and the old session has expired or is 28 days past refresh"); + } + + _logger.Info($"Refreshed session since it was expired or past 28 days refresh period. New session {SessionName}"); + } + + if (SessionHasExpired(SoftRefreshIntervalInDays)) + { + // The Mux sessions have a lifespan of 28 days. We check if we need a session refresh in every request needing the session + // If the timespan of a request needing a session and the session creation time is greater than 7 days, we proactively refresh the mux session + // The request can safely use the older session since it is still valid while we do this refresh to fetch the new session. + // Hence fire and forget the session refresh. + _ = Task.Run(() => UpdateMuxSession(true, SoftRefreshIntervalInDays)); + } + } + + // internal for testing + internal bool SessionHasExpired(double intervalInDays = SoftRefreshIntervalInDays) + { + DateTime currentTime = _clock.GetCurrentDateTimeUtc(); + DateTime? sessionCreateTime = _session?.CreateTime.ToDateTime(); // Inherent conversion into UTC DateTime + + if (_session == null || _session.Expired || currentTime - sessionCreateTime >= TimeSpan.FromDays(intervalInDays)) + { + return true; + } + + return false; + } + + private async Task CreateOrRefreshSessionsAsync(CancellationToken cancellationToken, bool needsRefresh = false) + { + try + { + var callSettings = Client.Settings.CreateSessionSettings + .WithExpiration(Expiration.FromTimeout(Options.Timeout)) + .WithCancellationToken(cancellationToken); + + Session multiplexSession; + + bool acquiredSemaphore = false; // Create a task and check non null for multiple threads initiating refresh + try + { + if (needsRefresh && MarkedForRefresh && !SessionHasExpired(ForceRefreshIntervalInDays)) + { + // If the refresh was triggered for the soft refresh timeline (7 days) + // Some other thread has already marked this session to be refreshed + // Any subsequent request threads can continue using the 'stale' session so let's not block + // On the other hand if the refresh is for the forced refresh timeline (28 days) + // Any subsequent request threads need to be blocked on the Session refresh + return; + } + + await _sessionCreateSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + acquiredSemaphore = true; + + if (_session == null || (needsRefresh && SessionHasExpired())) + { + Interlocked.Exchange(ref _markedForRefresh, 1); + multiplexSession = await Client.CreateSessionAsync(_createSessionRequestTemplate, callSettings).ConfigureAwait(false); + + Interlocked.Exchange(ref _session, multiplexSession); + Interlocked.Exchange(ref _markedForRefresh, 0); + } + } + catch (OperationCanceledException) + { + _logger.Warn(() => $"Creation request cancelled before we could procure a Multiplex Session for DatabaseName: {DatabaseName}, DatabaseRole: {DatabaseRole}"); + throw; + } + finally + { + if (acquiredSemaphore) + { + _sessionCreateSemaphore.Release(); + } + } + } + catch (Exception e) + { + _logger.Warn(() => $"Failed to create multiplex session for DatabaseName: {DatabaseName}, DatabaseRole: {DatabaseRole}", e); + throw; + } + finally + { + // Nothing to do here since for legacy SessionPool we had to have some logging for when the pool went from healthy to unhealthy. + // This could mean n number of things went wrong in the pool + // But with the MUX session, we essentially only have 1 session we need to manage per client. + // So there is no case of the mux session going back and forth in terms of its healthiness. + } + + } + + /// + /// + /// + public sealed partial class SessionBuilder + { + /// + /// + /// + public SessionBuilder(DatabaseName databaseName, SpannerClient client) + { + DatabaseName = GaxPreconditions.CheckNotNull(databaseName, nameof(databaseName)); + Client = GaxPreconditions.CheckNotNull(client, nameof(client)); + } + + /// + /// The options governing this multiplex session. + /// + public ManagedSessionOptions Options { get; set; } + + /// + /// The database for this multiplex session + /// + public DatabaseName DatabaseName { get; set; } + + /// + /// The database role of the multiplex session + /// + public string DatabaseRole { get; set; } + + /// + /// The client used for all operations in this multiplex session. + /// + public SpannerClient Client { get; set; } + + /// + /// + /// + /// + /// + public async Task BuildAsync(CancellationToken cancellationToken = default) + { + ManagedSession multiplexSession = new ManagedSession(Client, DatabaseName, DatabaseRole, Options); + + await multiplexSession.CreateOrRefreshSessionsAsync(cancellationToken).ConfigureAwait(false); + + return multiplexSession; + } + } +} diff --git a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSessionOptions.cs b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSessionOptions.cs new file mode 100644 index 000000000000..b9f8829d3ec5 --- /dev/null +++ b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedSessionOptions.cs @@ -0,0 +1,57 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Google.Cloud.Spanner.V1; + +/// +/// +/// +public class ManagedSessionOptions +{ + private TimeSpan _timeout = TimeSpan.FromSeconds(60); + + /// + /// Constructs a new with default values. + /// + public ManagedSessionOptions() + { + } + + /// + /// The total time allowed for a network call to the Cloud Spanner server, including retries. This setting + /// is applied to calls to create session as well as beginning transactions. + /// + /// + /// + /// This value must be positive. The default value is one minute. + /// + /// + public TimeSpan Timeout + { + get => _timeout; + set => _timeout = CheckPositiveTimeSpan(value); + } + + // TODO: Move to GAX if we find we need it in other libraries. (We have CheckNonNegative already.) + private static TimeSpan CheckPositiveTimeSpan(TimeSpan value) + { + if (value.Ticks <= 0) + { + throw new ArgumentOutOfRangeException("value", "Value must be a positive TimeSpan"); + } + return value; + } +} diff --git a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedTransaction.cs b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedTransaction.cs new file mode 100644 index 000000000000..0bac4e39d91c --- /dev/null +++ b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ManagedTransaction.cs @@ -0,0 +1,752 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Api.Gax; +using Google.Api.Gax.Grpc; +using Google.Cloud.Spanner.V1.Internal; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using static Google.Cloud.Spanner.V1.TransactionOptions; + +namespace Google.Cloud.Spanner.V1 +{ + /// + /// + /// + public partial class ManagedTransaction + { + private readonly ManagedSession _multiplexSession; + private Transaction _transaction; + private readonly object _transactionCreationTaskLock = new object(); + private Task _transactionCreationTask; + private readonly object _precommitTokenUpdateLock = new object(); + + /// + /// The name of the session. This is never null. + /// + public SessionName SessionName => _multiplexSession.SessionName; + + /// + /// The Spanner session resource associated to this pooled session. + /// Won't be null. + /// + internal Session Session => _multiplexSession.Session; + + /// + /// The options for the transaction that is or will be associated with this session. Won't be null. + /// + /// + /// Will be if + /// is null. + /// + internal TransactionOptions TransactionOptions { get; } + + /// + /// The transaction mode for the transaction that is or may be associated with this session. + /// Available for testing. + /// + internal TransactionOptions.ModeOneofCase TransactionMode => TransactionOptions.ModeCase; + + private SpannerClient Client => _multiplexSession.Client; + + /// + /// Whether the transaction associated to this session is single use or not. + /// If this is true then will be + /// and will be null. + /// + internal bool SingleUseTransaction { get; } + + /// + /// The ID of the transaction. May be null. + /// + /// + /// Transactions are acquired when they are needed so this will be null in the + /// following cases: + /// + /// + /// is , + /// including when the session is idle in the pool. + /// + /// + /// is true. No transactions will exist client side. + /// + /// + /// The session has been acquired from the pool with some transaction options but no command execution has + /// been attempted since. + /// + /// + /// The first command execution is underway and the transaction is being created either + /// by inlining or explicitly. + /// + /// + /// + public ByteString TransactionId => Interlocked.CompareExchange(ref _transaction, null, null)?.Id; + + /// + /// The read timestamp of the transaction. May be null. + /// + /// + /// Will be set iif a transaction has been started and represents read-only options + /// with set to true + /// or if this session was created using + /// and a value was provided for the parameter. + /// + public Timestamp ReadTimestamp => Interlocked.CompareExchange(ref _transaction, null, null)?.ReadTimestamp; + + // internal for testing + internal MultiplexedSessionPrecommitToken PrecommitToken { get; set; } + + /// + /// + /// + /// + /// + /// + /// + /// + public ManagedTransaction(ManagedSession multiplexSession, ByteString transactionId, TransactionOptions transactionOptions, bool singleUseTransaction, Timestamp readTimestamp) + { + _multiplexSession = multiplexSession; + + TransactionOptions = transactionOptions?.Clone() ?? new TransactionOptions(); + + GaxPreconditions.CheckArgument( + TransactionOptions.ModeCase == ModeOneofCase.ReadOnly || !singleUseTransaction, + nameof(singleUseTransaction), + "Single use transactions are only supported for read-only transactions."); + GaxPreconditions.CheckArgument( + transactionId is null || TransactionOptions.ModeCase != ModeOneofCase.None, + nameof(transactionOptions), + $"No transaction options were specified for the given transasaction ID {transactionId is null}, {transactionId?.ToBase64()}, {TransactionOptions.ModeCase}."); + GaxPreconditions.CheckArgument( + readTimestamp is null || transactionId is not null, + nameof(readTimestamp), + "A read timestamp can only be specified if a transaction ID is also being specified."); + + SingleUseTransaction = singleUseTransaction; + if (transactionId is not null) + { + // We don't need to lock here, this is the constructor. + _transaction = new Transaction + { + Id = transactionId, + ReadTimestamp = readTimestamp, + }; + } + } + + internal ManagedTransaction WithTransaction(ByteString transactionId, TransactionOptions transactionOptions, bool singleUseTransaction, Timestamp readTimestamp = null) + { + return new ManagedTransaction(_multiplexSession, transactionId, transactionOptions, singleUseTransaction, readTimestamp); + } + + private Mutation MaybeFetchMutationKey(Protobuf.Collections.RepeatedField mutations) + { + if(mutations.Count < 1) + { + return null; + } + + // Fetch first mutation in list, we will return the first mutation by default if it meets all conditions + Mutation key = mutations.ElementAt(0); + if(key.Delete?.KeySet.Keys.Count < 1) + { + return null; + } + + return key; + } + + /// + /// Decides whether we need a transaction ID or not and whether that can be obtained by transaction inlining. + /// Sets the correct transaction selector before executing the given command. + /// + /// All RPCs executed within the session should call this method for guaranteeing they are + /// using the correct transaction selector. + /// The type of the command response. + /// Called by this method to set the correct transaction selector. + /// The command (RPC) that will be executed after the transaction selector has maybe been set. + /// Callers may fail if command is executed but no transaction selector has been set. + /// Will extract transaction information from the command's response if + /// transaction inlining was succesful. May be null, which indicates that command does not support transaction inlining. + /// If true, transaction creation may be skipped. This is used by commit and rollback + /// so that a transaction is not created just for inmediate commit or rollback. Note that if there are pending mutations, commit + /// should set this parameter to false. + /// + /// The cancellation token for the operation. + /// A task whose result will be the result from having executed . + internal async Task ExecuteMaybeWithTransactionSelectorAsync( + Action transactionSelectorSetter, + Func> commandAsync, + Func inlinedTransactionExtractor, + bool skipTransactionCreation, + CancellationToken cancellationToken, + Mutation mutationKey = null) + { + // If this session is configured to use no transaction we just execute the command. + if (TransactionOptions.ModeCase == ModeOneofCase.None) + { + return await commandAsync().ConfigureAwait(false); + } + + // If this is to be a single use transaction, we set the selector to single use and execute the command. + if (SingleUseTransaction) + { + transactionSelectorSetter(new TransactionSelector { SingleUse = TransactionOptions }); + return await commandAsync().ConfigureAwait(false); + } + + // If we already have a transaction ID, we set the selector to that and execute the command. + // TransactionId is accessed and modified via Interlock.CompareExchange so these two are atomic operations. + // But also, if TransactionId is about to be modified right after this check, that's not a problem, because next + // we'll be awaiting on the task that does the modifying. + if (TransactionId is ByteString transactionId) + { + transactionSelectorSetter(new TransactionSelector { Id = transactionId }); + return await commandAsync().ConfigureAwait(false); + } + + // We now know that we don't have a transaction ID but we need one to execute + // the command we have been given. + // We now need to check if we are already creating a transaction or not. + // If we are, we just get ready to wait for it. + // If we are not, but we need to, we start and save the task that does so, + // and get ready to wait for it. + + // This is the function that will wait for the transaction task to be done. + // We need to initialize a function within the lock, so we can execute + // async code, outside the lock. + // We initialize it with just command, in case no transaction is being created + // and the caller does not require one. This is the case for commits and rollbacks + // executed when no transaction has been created before. + // Commits and rollbacks will know how to handle transaction absence. + Func> commandMaybeWithTransactionAsync = commandAsync; + + lock (_transactionCreationTaskLock) + { + // We are not creating a transaction. We might need to do so. + if (_transactionCreationTask is null) + { + // We need to create a transaction. + if (!skipTransactionCreation) + { + // The calling command does not support inlining + // or the transaction mode Partitioned DML, which cannot be inline. + // Either way we need to create a transaction explicitly. + if (inlinedTransactionExtractor is null || TransactionOptions.ModeCase == ModeOneofCase.PartitionedDml) + { + _transactionCreationTask = Task.Run(() => SetExplicitTransactionAsync(cancellationToken), cancellationToken); + commandMaybeWithTransactionAsync = () => CommandWithTransactionAsync(cancellationToken); + } + // The calling command supports inlining. + // We attempt inlining but if that fails, we create a transaction explicitly. + else + { + // Create a task for executing the command which inlines transaction creation. + // If this task succeeds we'll have both the transaction ID and the response from the command. + // If this task fails we'll have to attempt to begin a transaction explicitly, which will give us a transaction ID, + // and then we'll have to execute the command with that transaction. + Task commandWithInliningTask = Task.Run(CommandWithInliningAsync, cancellationToken); + + // Now, create two tasks, one that is done when we have a transaction ID (via inlining or explicit), + // and one that is done when the command is done (with inlining or explicit). + + // The transaction creation task is the combination of attempting inlining, + // and if that fails, explicitly creating a transaction. + _transactionCreationTask = Task.Run(async () => + { + try + { + await SetInlinedTransactionAsync(commandWithInliningTask).ConfigureAwait(false); + } + catch (Exception ex) + { + Client.Settings.Logger.Warn("Transaction creation via inlining failed. " + + "Attempting to begin an explicit transaction.", + ex); + await SetExplicitTransactionAsync(cancellationToken).ConfigureAwait(false); + } + }, cancellationToken); + + // The command execution task is the combination of attempting inlining, + // and if that fails, executing the command with the explicitly created transaction. + commandMaybeWithTransactionAsync = async () => + { + try + { + var response = await commandWithInliningTask.ConfigureAwait(false); + // If we are here, inlining was successful so we have a transaction. + // We only wait for the _transactionCreationTaks to be done to guarantee + // that the transaction ID obtained via inlining has been stored and can + // be access via TransactionId. In turn this guarantees command executors + // that there's an ID available inmediately after a successful command execution. + await _transactionCreationTask.ConfigureAwait(false); + return response; + } + catch (Exception ex) + { + Client.Settings.Logger.Warn("Command execution with transaction inlining failed. " + + "Waiting for an explicit transaction to be created to attempt command execution.", + ex); + // If we got here, transaction inlining (plus command execution) failed. + // That means that _transactionCreationTask is attempting to created an explicit + // transaction. + // So now we wait for that transaction to be created and the execute the command normally. + return await CommandWithTransactionAsync(cancellationToken).ConfigureAwait(false); + } + }; + } + } + + // No transaction is being created, but we don't need to do so. + // commandWithTransactionAsync is already initialized to just commandAsync. + } + // We are creating a transaction, let's get ready to wait for that to be done and use it. + else + { + commandMaybeWithTransactionAsync = () => CommandWithTransactionAsync(cancellationToken); + } + } + + return await commandMaybeWithTransactionAsync().ConfigureAwait(false); + + async Task CommandWithTransactionAsync(CancellationToken cancellationToken) + { + // This is only called when we know the _transactionCreationTask has been initialized + await _transactionCreationTask.WithCancellationToken(cancellationToken).ConfigureAwait(false); + // Now we know there's a transaction id. + transactionSelectorSetter(new TransactionSelector { Id = TransactionId }); + return await commandAsync().ConfigureAwait(false); + } + + Task CommandWithInliningAsync() + { + transactionSelectorSetter(new TransactionSelector { Begin = TransactionOptions }); + return commandAsync(); + } + + async Task SetExplicitTransactionAsync(CancellationToken cancellationToken) + { + Transaction transaction = await BeginTransactionAsync(cancellationToken, mutationKey).ConfigureAwait(false); + SetTransaction(transaction); + } + + async Task SetInlinedTransactionAsync(Task commandWithInliningTask) + { + TResponse response = await commandWithInliningTask.ConfigureAwait(false); + Transaction transaction = inlinedTransactionExtractor(response) + ?? throw new InvalidOperationException("The inlined transaction extractor returned a null transaction. " + + "This is possibly because of a bug in library code or because an operation that supported transaction inlining has stopped doing so."); + SetTransaction(transaction); + } + + void SetTransaction(Transaction transaction) + { + if (Interlocked.CompareExchange(ref _transaction, transaction, null) is not null) + { + throw new InvalidOperationException("A transaction has already been set on this instance. This is a bug in library code."); + } + } + + async Task BeginTransactionAsync(CancellationToken cancellationToken, Mutation mutationKey = null) + { + var request = new BeginTransactionRequest + { + Options = TransactionOptions, + SessionAsSessionName = SessionName, + MutationKey = mutationKey + }; + var callSettings = Client.Settings.BeginTransactionSettings + .WithExpiration(Expiration.FromTimeout(_multiplexSession.Options.Timeout)) + .WithCancellationToken(cancellationToken); + + Transaction response = await RecordSuccessAndExpiredSessions(Client.BeginTransactionAsync(request, callSettings)).ConfigureAwait(false); + UpdatePrecommitToken(response.PrecommitToken); + + return response; + } + } + + /// + /// Executes a Commit RPC asynchronously. + /// + /// The commit request. Must not be null. The request will be modified with session and transaction details + /// from this object. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public async Task CommitAsync(CommitRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + request.PrecommitToken = FetchPrecommitToken(); + + return await ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: CommitAsync, + inlinedTransactionExtractor: null, // Commit does not support inline transactions. + skipTransactionCreation: request.Mutations.Count == 0, // If there are only mutations we won't have a transaction but we need one. + callSettings?.CancellationToken ?? default, + // Multiplex sessions needs a mutation key in transaction create for a purely mutation based transaction + mutationKey: request.Mutations.Count > 0 ? MaybeFetchMutationKey(request.Mutations) : null).ConfigureAwait(false); + + void SetCommandTransaction(TransactionSelector transactionSelector) + { + switch (transactionSelector.SelectorCase) + { + case TransactionSelector.SelectorOneofCase.Id: + request.TransactionId = transactionSelector.Id; + break; + case TransactionSelector.SelectorOneofCase.Begin: + throw new InvalidOperationException("Commit does not support inline transactions. This is a bug in library code."); + case TransactionSelector.SelectorOneofCase.SingleUse: + throw new InvalidOperationException("A single use transaction cannot be committed."); + default: + throw new InvalidOperationException("Cannot commit with no associated transaction"); + } + } + + async Task CommitAsync() + { + // If a transaction had been started, by now SetTransaction should have been called with a transaction ID. + // If not, there's an attempt to commit a non-existent transaction. + if (request.TransactionId is null || request.TransactionId.IsEmpty) + { + throw new InvalidOperationException("Cannot commit without an associated transaction. " + + "A transaction has not been acquired because no command execution has been attempted."); + } + + CommitResponse finalResponse; + + do + { + // This loop will keep executing as long as we are signaled by Spanner to retry the Commit + // It only exits if we no longer receive a PrecommitToken based retry enum from backend + finalResponse = await ExecuteCommitOnceAsync(request, callSettings).ConfigureAwait(false); + } while (finalResponse.MultiplexedSessionRetryCase == CommitResponse.MultiplexedSessionRetryOneofCase.PrecommitToken); + + return finalResponse; + + async Task ExecuteCommitOnceAsync(CommitRequest request, CallSettings callSettings) + { + // The original logic of the Commit call and session updates + request.PrecommitToken = FetchPrecommitToken(); + CommitResponse response = await RecordSuccessAndExpiredSessions(Client.CommitAsync(request, callSettings)).ConfigureAwait(false); + UpdatePrecommitToken(response.PrecommitToken); + return response; + } + } + } + + /// + /// Executes a Rollback RPC asynchronously. + /// + /// The rollback request. Must not be null. The request will be modified with session and transaction details + /// from this object. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. + public async Task RollbackAsync(RollbackRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + + await ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: RollbackAsync, + inlinedTransactionExtractor: null, // Rollback does not support inline transactions. + skipTransactionCreation: true, // If there's no transaction by the time roll back is called, we fail, we don't need to create one. + callSettings?.CancellationToken ?? default).ConfigureAwait(false); + + void SetCommandTransaction(TransactionSelector transactionSelector) + { + switch (transactionSelector.SelectorCase) + { + case TransactionSelector.SelectorOneofCase.Id: + request.TransactionId = transactionSelector.Id; + break; + case TransactionSelector.SelectorOneofCase.Begin: + throw new InvalidOperationException("Rollback does not support inline transactions. This is a bug in library code."); + case TransactionSelector.SelectorOneofCase.SingleUse: + throw new InvalidOperationException("A single use transaction cannot be rolled back."); + default: + throw new InvalidOperationException("Cannot roll back with no associated transaction"); + } + } + + async Task RollbackAsync() + { + // If a transaction had been started, by now SetTransaction should have been called with a transaction ID. + // If not, there's an attempt to roll back a transaction that was never started. + // Possibly starting the transaction is what failed, but if we fail here as well, we are passing the burden to calling code + // to know whether a transaction was actually acquired before calling rollback, and we don't want to do that. + // Attemting to roll back an empty transaction is no-op. + if (request.TransactionId is null || request.TransactionId.IsEmpty) + { + return false; + } + + await RecordSuccessAndExpiredSessions(Client.RollbackAsync(request, callSettings)).ConfigureAwait(false); + //MarkAsCommittedOrRolledBack(); + // Just so we can use the same ExecuteMaybeWithTransactionAsync method that expects a result. + return true; + } + } + + /// + /// Executes a PartitionRead RPC asynchronously. + /// + /// The partitioning request. Must not be null. The request will be modified with session details + /// from this object. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public Task PartitionReadAsync(PartitionReadRequest request, CallSettings callSettings) => + PartitionReadOrQueryAsync(PartitionReadOrQueryRequest.FromRequest(request), callSettings); + + /// + /// Executes a PartitionQuery RPC asynchronously. + /// + /// The partitioning request. Must not be null. The request will be modified with session details + /// from this object. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public Task PartitionQueryAsync(PartitionQueryRequest request, CallSettings callSettings) => + PartitionReadOrQueryAsync(PartitionReadOrQueryRequest.FromRequest(request), callSettings); + + /// + /// Executes a PartitionRead RPC asynchronously. + /// + /// The partitioning request. Must not be null. The request will be modified with session details + /// from this object. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + internal async Task PartitionReadOrQueryAsync(PartitionReadOrQueryRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + + return await ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: PartitionReadOrQueryAsync, + inlinedTransactionExtractor: GetInlinedTransaction, + skipTransactionCreation: false, + callSettings?.CancellationToken ?? default).ConfigureAwait(false); + + void SetCommandTransaction(TransactionSelector transactionSelector) + { + switch (transactionSelector.SelectorCase) + { + case TransactionSelector.SelectorOneofCase.Id: + case TransactionSelector.SelectorOneofCase.Begin: + request.Transaction = transactionSelector; + break; + case TransactionSelector.SelectorOneofCase.SingleUse: + throw new InvalidOperationException("A single use transaction cannot be used for creating partitioned reads or queries."); + default: + throw new InvalidOperationException("Cannot call PartitionReadOrQueryAsync with no associated transaction."); + } + } + + Task PartitionReadOrQueryAsync() + { + // By now SetTransaction should have been called with a valid transaction selector. + // If not, there's a bug in code because we said not to skip transaction creation. + if (request.Transaction is null) + { + throw new InvalidOperationException("Cannot call PartitionReadOrQueryAsync with no associated transaction."); + } + + return RecordSuccessAndExpiredSessions(request.PartitionAsync(Client, callSettings)); + } + + Transaction GetInlinedTransaction(PartitionResponse response) => response?.Transaction; + } + + /// + /// Creates a for the given request. + /// + /// + /// The read request. Must not be null. + /// Will be modified to include session information from this pooled session. + /// May be modified to include transaction and directed read options information + /// from this pooled session and its underlying . + /// + /// If not null, applies overrides to this RPC call. + /// A for the streaming SQL request. + public async Task ReadStreamReaderAsync(ReadRequest request, CallSettings callSettings) => + await ExecuteReadOrQueryStreamReader(ReadOrQueryRequest.FromRequest(request), callSettings).ConfigureAwait(false); + + /// + /// Creates a for the given request. + /// + /// + /// The query request. Must not be null. + /// Will be modified to include session information from this pooled session. + /// May be modified to include transaction and directed read options information + /// from this pooled session and its underlying . + /// + /// If not null, applies overrides to this RPC call. + /// A for the streaming SQL request. + public async Task ExecuteSqlStreamReaderAsync(ExecuteSqlRequest request, CallSettings callSettings) => + await ExecuteReadOrQueryStreamReader(ReadOrQueryRequest.FromRequest(request), callSettings).ConfigureAwait(false); + + /// + /// Creates a for the given request + /// + /// The read request. Must not be null. The request will be modified with session and transaction details + /// from this object. If this object's is null, the request's transaction is not modified. + /// If not null, applies overrides to this RPC call. + /// A for the streaming read request. + internal async Task ExecuteReadOrQueryStreamReader(ReadOrQueryRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + SpannerClientImpl.ApplyResourcePrefixHeaderFromSession(ref callSettings, request.Session); + Client.MaybeApplyRouteToLeaderHeader(ref callSettings, TransactionMode); + MaybeApplyDirectedReadOptions(request.UnderlyingRequest); + + ResultStream stream = new ResultStream(Client, request, this, callSettings); + return new ReliableStreamReader(stream, Client.Settings.Logger); + } + + /// + /// Executes an ExecuteSql RPC asynchronously. + /// + /// The query request. Must not be null. + /// Will be modified to include session information from this pooled session. + /// May be modified to include transaction and directed read options information + /// from this pooled session and its underlying . + /// + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public async Task ExecuteSqlAsync(ExecuteSqlRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + + return await ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: ExecuteSqlAsync, + inlinedTransactionExtractor: GetInlinedTransaction, + skipTransactionCreation: false, + callSettings?.CancellationToken ?? default).ConfigureAwait(false); + + void SetCommandTransaction(TransactionSelector transactionSelector) => request.Transaction = transactionSelector; + + async Task ExecuteSqlAsync() // TODO: Purva check consequences of making this an async method + { + Client.MaybeApplyRouteToLeaderHeader(ref callSettings, TransactionMode); + MaybeApplyDirectedReadOptions(request); + ResultSet response = await RecordSuccessAndExpiredSessions(Client.ExecuteSqlAsync(request, callSettings)).ConfigureAwait(false); + UpdatePrecommitToken(response.PrecommitToken); + return response; + } + + Transaction GetInlinedTransaction(ResultSet response) => response?.Metadata?.Transaction; + } + + /// + /// Executes an ExecuteBatchDml RPC asynchronously. + /// + /// The query request. Must not be null. The request will be modified with session and transaction details + /// from this object. If this object's is null, the request's transaction is not modified. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public async Task ExecuteBatchDmlAsync(ExecuteBatchDmlRequest request, CallSettings callSettings) + { + await MaybeWaitOnSessionRefresh().ConfigureAwait(false); + GaxPreconditions.CheckNotNull(request, nameof(request)); + + request.SessionAsSessionName = SessionName; + + return await ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: ExecuteBatchDmlAsync, + inlinedTransactionExtractor: GetInlinedTransaction, + skipTransactionCreation: false, + callSettings?.CancellationToken ?? default).ConfigureAwait(false); + + void SetCommandTransaction(TransactionSelector transactionSelector) => request.Transaction = transactionSelector; + + async Task ExecuteBatchDmlAsync() // TODO: Purva to check consequence of making this method async + { + ExecuteBatchDmlResponse response = await RecordSuccessAndExpiredSessions(Client.ExecuteBatchDmlAsync(request, callSettings)).ConfigureAwait(false); + UpdatePrecommitToken(response.PrecommitToken); + return response; + } + + Transaction GetInlinedTransaction(ExecuteBatchDmlResponse response) => response?.ResultSets?.FirstOrDefault()?.Metadata?.Transaction; + } + + private void MaybeApplyDirectedReadOptions(IReadOrQueryRequest request) + { + if (TransactionMode == ModeOneofCase.ReadOnly // Directed reads apply only to single use or read only transactions. Single use are read only. + && request.DirectedReadOptions is null) // Request specific options have priority over client options. + { + request.DirectedReadOptions = Client.Settings.DirectedReadOptions; + } + + // We don't validate that DirectedReadOptions is null when this is a non-read-only transaction. + // We just pass the request along as we received it. The service should fail if there are options set. + // This was agreed as part of the client library desing. + } + + private async Task MaybeWaitOnSessionRefresh() + { + await _multiplexSession.MaybeRefreshWithTimePeriodCheck().ConfigureAwait(false); + } + + private async Task RecordSuccessAndExpiredSessions(Task task) + { + var result = await task.WithSessionExpiryChecking(Session).ConfigureAwait(false); + return result; + } + + private async Task RecordSuccessAndExpiredSessions(Task task) + { + await task.WithSessionExpiryChecking(Session).ConfigureAwait(false); + } + + internal void UpdatePrecommitToken(MultiplexedSessionPrecommitToken token) + { + lock (_precommitTokenUpdateLock) + { + if (PrecommitToken == null || PrecommitToken.SeqNum < token?.SeqNum) + { + PrecommitToken = token; + } + } + } + + internal MultiplexedSessionPrecommitToken FetchPrecommitToken() + { + lock (_precommitTokenUpdateLock) + { + return PrecommitToken; + } + } + } +} diff --git a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ReadOrQueryRequest.cs b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ReadOrQueryRequest.cs index dfee013c7a58..5092671cfcbe 100644 --- a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ReadOrQueryRequest.cs +++ b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ReadOrQueryRequest.cs @@ -287,6 +287,15 @@ public ByteString PartitionToken internal AsyncServerStreamingCall ExecuteStreaming(SpannerClient client, CallSettings callSettings) => UnderlyingRequest.ExecuteStreaming(client, callSettings); + /// + /// Creates a for this request + /// + /// The managed transaction to use for the request. + /// If not null, applies overrides to this RPC call. + /// A for this request. + public async Task ExecuteReadOrQueryStreamReader(ManagedTransaction transaction, CallSettings callSettings) => + await transaction.ExecuteReadOrQueryStreamReader(this, callSettings).ConfigureAwait(false); + /// /// Creates a for this request /// @@ -369,6 +378,15 @@ public PartitionOptions PartitionOptions public Task PartitionReadOrQueryAsync(PooledSession session, CallSettings callSettings) => session.PartitionReadOrQueryAsync(this, callSettings); + /// + /// Executes a PartitionRead or PartitionQuery RPC asynchronously. + /// + /// The managed transaction to use for the request. + /// If not null, applies overrides to this RPC call. + /// A task representing the asynchronous operation. When the task completes, the result is the response from the RPC. + public Task PartitionReadOrQueryAsync(ManagedTransaction transaction, CallSettings callSettings) => + transaction.PartitionReadOrQueryAsync(this, callSettings); + /// public override bool Equals(object o) => o is PartitionReadOrQueryRequest request && request.Request.Equals(Request); diff --git a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ResultStream.cs b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ResultStream.cs index d5e3cb7a74f7..80fd802fdeb0 100644 --- a/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ResultStream.cs +++ b/apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/ResultStream.cs @@ -19,6 +19,7 @@ using Grpc.Core; using System; using System.Collections.Generic; +using System.Data.Common; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -51,6 +52,7 @@ internal sealed class ResultStream : IAsyncStreamReader, IDisp private readonly SpannerClient _client; private readonly ReadOrQueryRequest _request; private readonly PooledSession _pooledSession; + private readonly ManagedTransaction _transaction; private readonly CallSettings _callSettings; private readonly RetrySettings _retrySettings; private readonly int _maxBufferSize; @@ -71,6 +73,34 @@ internal ResultStream(SpannerClient client, ReadOrQueryRequest request, PooledSe { } + /// + /// Constructor for normal usage, taking in a transaction, with default buffer size, backoff settings and jitter. + /// + internal ResultStream(SpannerClient client, ReadOrQueryRequest request, ManagedTransaction transaction, CallSettings callSettings) + : this(client, request, transaction, callSettings, DefaultMaxBufferSize, s_defaultRetrySettings) + { + } + + /// + /// Constructor with complete control that does not perform any validation. + /// + internal ResultStream( + SpannerClient client, + ReadOrQueryRequest request, + ManagedTransaction transaction, + CallSettings callSettings, + int maxBufferSize, + RetrySettings retrySettings) + { + _buffer = new LinkedList(); + _client = GaxPreconditions.CheckNotNull(client, nameof(client)); + _request = GaxPreconditions.CheckNotNull(request, nameof(request)); + _transaction = GaxPreconditions.CheckNotNull(transaction, nameof(transaction)); + _callSettings = callSettings; + _maxBufferSize = GaxPreconditions.CheckArgumentRange(maxBufferSize, nameof(maxBufferSize), 1, 10_000); + _retrySettings = GaxPreconditions.CheckNotNull(retrySettings, nameof(retrySettings)); + } + /// /// Constructor with complete control that does not perform any validation. /// @@ -105,6 +135,7 @@ public async Task MoveNext(CancellationToken cancellationToken) { var value = await ComputeNextAsync(cancellationToken).ConfigureAwait(false); Current = value; + _transaction?.UpdatePrecommitToken(value?.PrecommitToken); return value != null; } @@ -134,20 +165,33 @@ private async Task ComputeNextAsync(CancellationToken cancella bool hasNext = false; if (_grpcCall is null) { - // Whenever we have to execute the gRPC streaming call we ask the pooled session - // for the transaction. Only the first time the gRPC streaming call is executed + // Only the first time the gRPC streaming call is executed // the transaction will be inlined, if there's not a transaction already. Subsequent // times will just get the same transacton ID. So in principle, we only need to ask // for the transaction the first and second times the gRPC streaming call is executed, // but doing it every time simplifies implementation and adds little overhead, because // once there's a transaction ID, ExecuteMaybeWithTransactionSelectorAsync returns - // inmediately. - await _pooledSession.ExecuteMaybeWithTransactionSelectorAsync( - transactionSelectorSetter: SetCommandTransaction, - commandAsync: ExecuteStreamingAsync, - inlinedTransactionExtractor: GetInlinedTransaction, - skipTransactionCreation: false, - cancellationToken).ConfigureAwait(false); + // immediately. + if(_transaction != null) + { + await _transaction.ExecuteMaybeWithTransactionSelectorAsync( + transactionSelectorSetter: SetCommandTransaction, + commandAsync: ExecuteStreamingAsync, + inlinedTransactionExtractor: GetInlinedTransaction, + skipTransactionCreation: false, + cancellationToken).ConfigureAwait(false); + } + else + { + // PooledSession will eventually be deprecated + // We will get rid of this condition then. + //await _pooledSession.ExecuteMaybeWithTransactionSelectorAsync( + // transactionSelectorSetter: SetCommandTransaction, + // commandAsync: ExecuteStreamingAsync, + // inlinedTransactionExtractor: GetInlinedTransaction, + // skipTransactionCreation: false, + // cancellationToken).ConfigureAwait(false); + } void SetCommandTransaction(TransactionSelector transactionSelector) => _request.Transaction = transactionSelector; @@ -173,7 +217,7 @@ async Task ExecuteStreamingAsync() hasNext = await MoveNextAsync().ConfigureAwait(false); } - Task MoveNextAsync() => _grpcCall.ResponseStream.MoveNext(cancellationToken).WithSessionExpiryChecking(_pooledSession.Session); + Task MoveNextAsync() => _grpcCall.ResponseStream.MoveNext(cancellationToken).WithSessionExpiryChecking(_transaction != null ?_transaction.Session : _pooledSession.Session); retryState.Reset();