|
28 | 28 | import com.google.spanner.v1.CommitResponse; |
29 | 29 | import com.google.spanner.v1.ExecuteSqlRequest; |
30 | 30 | import com.google.spanner.v1.Group; |
| 31 | +import com.google.spanner.v1.PartialResultSet; |
31 | 32 | import com.google.spanner.v1.Range; |
| 33 | +import com.google.spanner.v1.ReadRequest; |
32 | 34 | import com.google.spanner.v1.ResultSet; |
| 35 | +import com.google.spanner.v1.ResultSetMetadata; |
33 | 36 | import com.google.spanner.v1.RollbackRequest; |
34 | 37 | import com.google.spanner.v1.RoutingHint; |
35 | 38 | import com.google.spanner.v1.SpannerGrpc; |
36 | 39 | import com.google.spanner.v1.Tablet; |
37 | 40 | import com.google.spanner.v1.Transaction; |
| 41 | +import com.google.spanner.v1.TransactionOptions; |
| 42 | +import com.google.spanner.v1.TransactionSelector; |
38 | 43 | import io.grpc.CallOptions; |
39 | 44 | import io.grpc.ClientCall; |
40 | 45 | import io.grpc.ManagedChannel; |
@@ -269,6 +274,221 @@ public void resultSetCacheUpdateRoutesSubsequentRequest() throws Exception { |
269 | 274 | assertThat(harness.endpointCache.callCountForAddress("routed:1234")).isEqualTo(1); |
270 | 275 | } |
271 | 276 |
|
| 277 | + @Test |
| 278 | + public void readOnlyTransactionRoutesEachReadIndependently() throws Exception { |
| 279 | + TestHarness harness = createHarness(); |
| 280 | + ByteString transactionId = ByteString.copyFromUtf8("ro-tx-1"); |
| 281 | + |
| 282 | + // 1. Begin a read-only transaction (stale read). |
| 283 | + ClientCall<BeginTransactionRequest, Transaction> beginCall = |
| 284 | + harness.channel.newCall(SpannerGrpc.getBeginTransactionMethod(), CallOptions.DEFAULT); |
| 285 | + CapturingListener<Transaction> beginListener = new CapturingListener<>(); |
| 286 | + beginCall.start(beginListener, new Metadata()); |
| 287 | + beginCall.sendMessage( |
| 288 | + BeginTransactionRequest.newBuilder() |
| 289 | + .setSession(SESSION) |
| 290 | + .setOptions( |
| 291 | + TransactionOptions.newBuilder() |
| 292 | + .setReadOnly( |
| 293 | + TransactionOptions.ReadOnly.newBuilder() |
| 294 | + .setReturnReadTimestamp(true) |
| 295 | + .build())) |
| 296 | + .build()); |
| 297 | + |
| 298 | + // BeginTransaction goes to default channel. |
| 299 | + assertThat(harness.defaultManagedChannel.callCount()).isEqualTo(1); |
| 300 | + |
| 301 | + @SuppressWarnings("unchecked") |
| 302 | + RecordingClientCall<BeginTransactionRequest, Transaction> beginDelegate = |
| 303 | + (RecordingClientCall<BeginTransactionRequest, Transaction>) |
| 304 | + harness.defaultManagedChannel.latestCall(); |
| 305 | + beginDelegate.emitOnMessage(Transaction.newBuilder().setId(transactionId).build()); |
| 306 | + beginDelegate.emitOnClose(Status.OK, new Metadata()); |
| 307 | + |
| 308 | + // 2. Populate cache with routing data for two different key ranges. |
| 309 | + CacheUpdate cacheUpdate = |
| 310 | + CacheUpdate.newBuilder() |
| 311 | + .setDatabaseId(7L) |
| 312 | + .addRange( |
| 313 | + Range.newBuilder() |
| 314 | + .setStartKey(bytes("a")) |
| 315 | + .setLimitKey(bytes("m")) |
| 316 | + .setGroupUid(1L) |
| 317 | + .setSplitId(1L) |
| 318 | + .setGeneration(bytes("1"))) |
| 319 | + .addRange( |
| 320 | + Range.newBuilder() |
| 321 | + .setStartKey(bytes("m")) |
| 322 | + .setLimitKey(bytes("z")) |
| 323 | + .setGroupUid(2L) |
| 324 | + .setSplitId(2L) |
| 325 | + .setGeneration(bytes("1"))) |
| 326 | + .addGroup( |
| 327 | + Group.newBuilder() |
| 328 | + .setGroupUid(1L) |
| 329 | + .setGeneration(bytes("1")) |
| 330 | + .addTablets( |
| 331 | + Tablet.newBuilder() |
| 332 | + .setTabletUid(1L) |
| 333 | + .setServerAddress("server-a:1234") |
| 334 | + .setIncarnation(bytes("1")) |
| 335 | + .setDistance(0))) |
| 336 | + .addGroup( |
| 337 | + Group.newBuilder() |
| 338 | + .setGroupUid(2L) |
| 339 | + .setGeneration(bytes("1")) |
| 340 | + .addTablets( |
| 341 | + Tablet.newBuilder() |
| 342 | + .setTabletUid(2L) |
| 343 | + .setServerAddress("server-b:1234") |
| 344 | + .setIncarnation(bytes("1")) |
| 345 | + .setDistance(0))) |
| 346 | + .build(); |
| 347 | + |
| 348 | + // Seed the cache via a dummy query response with cache update. |
| 349 | + ClientCall<ExecuteSqlRequest, ResultSet> seedCall = |
| 350 | + harness.channel.newCall(SpannerGrpc.getExecuteSqlMethod(), CallOptions.DEFAULT); |
| 351 | + seedCall.start(new CapturingListener<ResultSet>(), new Metadata()); |
| 352 | + seedCall.sendMessage( |
| 353 | + ExecuteSqlRequest.newBuilder() |
| 354 | + .setSession(SESSION) |
| 355 | + .setRoutingHint(RoutingHint.newBuilder().setKey(bytes("a")).build()) |
| 356 | + .build()); |
| 357 | + @SuppressWarnings("unchecked") |
| 358 | + RecordingClientCall<ExecuteSqlRequest, ResultSet> seedDelegate = |
| 359 | + (RecordingClientCall<ExecuteSqlRequest, ResultSet>) |
| 360 | + harness.defaultManagedChannel.latestCall(); |
| 361 | + seedDelegate.emitOnMessage(ResultSet.newBuilder().setCacheUpdate(cacheUpdate).build()); |
| 362 | + |
| 363 | + // 3. Send a streaming read with key in range [a, m) → should go to server-a. |
| 364 | + ClientCall<ReadRequest, PartialResultSet> readCallA = |
| 365 | + harness.channel.newCall(SpannerGrpc.getStreamingReadMethod(), CallOptions.DEFAULT); |
| 366 | + readCallA.start(new CapturingListener<PartialResultSet>(), new Metadata()); |
| 367 | + readCallA.sendMessage( |
| 368 | + ReadRequest.newBuilder() |
| 369 | + .setSession(SESSION) |
| 370 | + .setTransaction(TransactionSelector.newBuilder().setId(transactionId)) |
| 371 | + .setRoutingHint(RoutingHint.newBuilder().setKey(bytes("b")).build()) |
| 372 | + .build()); |
| 373 | + |
| 374 | + assertThat(harness.endpointCache.callCountForAddress("server-a:1234")).isEqualTo(1); |
| 375 | + |
| 376 | + // 4. Send an ExecuteStreamingSql with key in range [m, z) → should go to server-b. |
| 377 | + ClientCall<ExecuteSqlRequest, PartialResultSet> queryCallB = |
| 378 | + harness.channel.newCall(SpannerGrpc.getExecuteStreamingSqlMethod(), CallOptions.DEFAULT); |
| 379 | + queryCallB.start(new CapturingListener<PartialResultSet>(), new Metadata()); |
| 380 | + queryCallB.sendMessage( |
| 381 | + ExecuteSqlRequest.newBuilder() |
| 382 | + .setSession(SESSION) |
| 383 | + .setTransaction(TransactionSelector.newBuilder().setId(transactionId)) |
| 384 | + .setRoutingHint(RoutingHint.newBuilder().setKey(bytes("n")).build()) |
| 385 | + .build()); |
| 386 | + |
| 387 | + assertThat(harness.endpointCache.callCountForAddress("server-b:1234")).isEqualTo(1); |
| 388 | + |
| 389 | + // Neither read was pinned to the default host (besides the initial begin + seed). |
| 390 | + // default had: 1 begin + 1 seed = 2 calls |
| 391 | + assertThat(harness.defaultManagedChannel.callCount()).isEqualTo(2); |
| 392 | + } |
| 393 | + |
| 394 | + @Test |
| 395 | + public void readOnlyTransactionDoesNotRecordAffinity() throws Exception { |
| 396 | + TestHarness harness = createHarness(); |
| 397 | + ByteString transactionId = ByteString.copyFromUtf8("ro-tx-2"); |
| 398 | + |
| 399 | + // Begin a read-only transaction. |
| 400 | + ClientCall<BeginTransactionRequest, Transaction> beginCall = |
| 401 | + harness.channel.newCall(SpannerGrpc.getBeginTransactionMethod(), CallOptions.DEFAULT); |
| 402 | + beginCall.start(new CapturingListener<Transaction>(), new Metadata()); |
| 403 | + beginCall.sendMessage( |
| 404 | + BeginTransactionRequest.newBuilder() |
| 405 | + .setSession(SESSION) |
| 406 | + .setOptions( |
| 407 | + TransactionOptions.newBuilder() |
| 408 | + .setReadOnly( |
| 409 | + TransactionOptions.ReadOnly.newBuilder() |
| 410 | + .setReturnReadTimestamp(true) |
| 411 | + .build())) |
| 412 | + .build()); |
| 413 | + |
| 414 | + @SuppressWarnings("unchecked") |
| 415 | + RecordingClientCall<BeginTransactionRequest, Transaction> beginDelegate = |
| 416 | + (RecordingClientCall<BeginTransactionRequest, Transaction>) |
| 417 | + harness.defaultManagedChannel.latestCall(); |
| 418 | + beginDelegate.emitOnMessage(Transaction.newBuilder().setId(transactionId).build()); |
| 419 | + beginDelegate.emitOnClose(Status.OK, new Metadata()); |
| 420 | + |
| 421 | + // No affinity should be recorded for the default endpoint. |
| 422 | + // Verify by checking that the endpoint cache was never queried for affinity lookup. |
| 423 | + // The default endpoint getCount tracks affinity lookups. |
| 424 | + assertThat(harness.endpointCache.getCount(DEFAULT_ADDRESS)).isEqualTo(0); |
| 425 | + |
| 426 | + // Send a read using the transaction ID (no cache populated, so falls back to default). |
| 427 | + ClientCall<ExecuteSqlRequest, ResultSet> readCall = |
| 428 | + harness.channel.newCall(SpannerGrpc.getExecuteSqlMethod(), CallOptions.DEFAULT); |
| 429 | + readCall.start(new CapturingListener<ResultSet>(), new Metadata()); |
| 430 | + readCall.sendMessage( |
| 431 | + ExecuteSqlRequest.newBuilder() |
| 432 | + .setSession(SESSION) |
| 433 | + .setTransaction(TransactionSelector.newBuilder().setId(transactionId)) |
| 434 | + .build()); |
| 435 | + |
| 436 | + // The read goes to default (no cache data), but NOT because of affinity. |
| 437 | + // No affinity lookup should have been performed for the read-only txn. |
| 438 | + assertThat(harness.endpointCache.getCount(DEFAULT_ADDRESS)).isEqualTo(0); |
| 439 | + |
| 440 | + // Now receive a response with the transaction ID — should NOT record affinity. |
| 441 | + @SuppressWarnings("unchecked") |
| 442 | + RecordingClientCall<ExecuteSqlRequest, ResultSet> readDelegate = |
| 443 | + (RecordingClientCall<ExecuteSqlRequest, ResultSet>) |
| 444 | + harness.defaultManagedChannel.latestCall(); |
| 445 | + readDelegate.emitOnMessage( |
| 446 | + ResultSet.newBuilder() |
| 447 | + .setMetadata( |
| 448 | + ResultSetMetadata.newBuilder() |
| 449 | + .setTransaction(Transaction.newBuilder().setId(transactionId))) |
| 450 | + .build()); |
| 451 | + |
| 452 | + // Still no affinity recorded. |
| 453 | + assertThat(harness.endpointCache.getCount(DEFAULT_ADDRESS)).isEqualTo(0); |
| 454 | + } |
| 455 | + |
| 456 | + @Test |
| 457 | + public void readOnlyTransactionCleanupOnClose() throws Exception { |
| 458 | + TestHarness harness = createHarness(); |
| 459 | + ByteString transactionId = ByteString.copyFromUtf8("ro-tx-3"); |
| 460 | + |
| 461 | + // Begin a read-only transaction. |
| 462 | + ClientCall<BeginTransactionRequest, Transaction> beginCall = |
| 463 | + harness.channel.newCall(SpannerGrpc.getBeginTransactionMethod(), CallOptions.DEFAULT); |
| 464 | + beginCall.start(new CapturingListener<Transaction>(), new Metadata()); |
| 465 | + beginCall.sendMessage( |
| 466 | + BeginTransactionRequest.newBuilder() |
| 467 | + .setSession(SESSION) |
| 468 | + .setOptions( |
| 469 | + TransactionOptions.newBuilder() |
| 470 | + .setReadOnly( |
| 471 | + TransactionOptions.ReadOnly.newBuilder() |
| 472 | + .setReturnReadTimestamp(true) |
| 473 | + .build())) |
| 474 | + .build()); |
| 475 | + |
| 476 | + @SuppressWarnings("unchecked") |
| 477 | + RecordingClientCall<BeginTransactionRequest, Transaction> beginDelegate = |
| 478 | + (RecordingClientCall<BeginTransactionRequest, Transaction>) |
| 479 | + harness.defaultManagedChannel.latestCall(); |
| 480 | + beginDelegate.emitOnMessage(Transaction.newBuilder().setId(transactionId).build()); |
| 481 | + beginDelegate.emitOnClose(Status.OK, new Metadata()); |
| 482 | + |
| 483 | + // Clear transaction affinity (simulates MultiUseReadOnlyTransaction.close()). |
| 484 | + harness.channel.clearTransactionAffinity(transactionId); |
| 485 | + |
| 486 | + // After cleanup, reads with this transaction ID should use normal affinity logic. |
| 487 | + // This ensures no memory leak for the readOnlyTransactions map. |
| 488 | + // We can verify indirectly: a BeginTransaction for a read-write txn with the same ID |
| 489 | + // would record affinity normally. |
| 490 | + } |
| 491 | + |
272 | 492 | private static TestHarness createHarness() throws IOException { |
273 | 493 | FakeEndpointCache endpointCache = new FakeEndpointCache(DEFAULT_ADDRESS); |
274 | 494 | InstantiatingGrpcChannelProvider provider = |
|
0 commit comments