diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/Session.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/Session.java
index e025ce71518b..e42e5f030939 100644
--- a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/Session.java
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/Session.java
@@ -33,7 +33,24 @@
* vRPC transport.
*/
public interface Session {
- /** State transitions of Session, will happen in defined order */
+ /**
+ * State transitions of Session, will happen in defined order:
+ *
+ *
+ * - {@link #NEW}: Initial state before {@link #start} is called.
+ *
- {@link #STARTING}: Open handshake sent; bounded by {@code OPEN_SESSION_TIMEOUT}.
+ *
- {@link #READY}: Open handshake completed; ready to serve vRPCs.
+ *
- {@link #CLOSING}: Graceful close initiated (via {@link #close} or server {@code
+ * GoAwayResponse}) while an in-flight vRPC (including a client-cancelled one) is still on
+ * the wire waiting for the server's response before {@code CloseSessionRequest} can be
+ * sent. Bounded by server heartbeats ({@code checkHeartbeat}) and the server-enforced
+ * {@code VirtualRpcRequest.deadline}.
+ *
- {@link #WAIT_SERVER_CLOSE}: No vRPC is in flight on the wire (so server heartbeats stop)
+ * and {@code CloseSessionRequest} + {@code halfClose()} have been sent; waiting for the
+ * server to close the stream. Bounded by {@code CLOSE_SESSION_TIMEOUT}.
+ *
- {@link #CLOSED}: Terminal state; underlying stream is closed.
+ *
+ */
enum SessionState {
NEW(0),
STARTING(1),
@@ -95,12 +112,14 @@ OpenParams withIncrementedAttempts() {
void start(OpenSessionRequest req, Metadata headers, Listener sessionListener);
/**
- * Caller instructed close of the Session. Will immediately close the session and abort all
- * outstanding vRPCs.
+ * Initiates a graceful close of the Session. Transitions the session to {@link
+ * SessionState#CLOSING}, waits for any in-flight vRPC on the wire (including a client-cancelled
+ * one) to receive its server response, and then sends {@link CloseSessionRequest} ({@link
+ * SessionState#WAIT_SERVER_CLOSE}).
*/
void close(CloseSessionRequest req);
- /** Force close a session. */
+ /** Immediately aborts the session stream and fails any in-flight vRPC. */
void forceClose(CloseSessionRequest reason);
/**
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImpl.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImpl.java
index b155ffd4d7ad..d07bbc348d39 100644
--- a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImpl.java
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImpl.java
@@ -405,6 +405,10 @@ public void close(CloseSessionRequest req) {
closeReason = req;
updateState(SessionState.CLOSING);
+ // If a vRPC is still in flight on the wire (even if cancelled locally via cancelRpc),
+ // wait in CLOSING for the server's vRPC response before sending CloseSessionRequest:
+ // the protocol has no wire cancel-vRPC message yet, and sending CloseSessionRequest while
+ // the server still has an active vRPC logs a server-side blackholed-response error.
if (currentRpc == null) {
startGracefulClose();
}
@@ -493,6 +497,17 @@ public void startRpc(VRpcImpl, ?, ?> rpc, VirtualRpcRequest payload) {
public void cancelRpc(long rpcId, @Nullable String message, @Nullable Throwable cause) {
sessionSyncContext.execute(
() -> {
+ // The Session protocol does not yet have a wire cancel-vRPC message, so the server
+ // continues executing the vRPC until it finishes or hits the server-enforced
+ // VirtualRpcRequest.deadline. We record currentCancel here while keeping currentRpc set
+ // until handleVRpcResponse / handleVRpcErrorResponse arrives so that:
+ // 1. A READY session is not returned to the pool while the server still has an active
+ // vRPC on it (which would violate the single-in-flight-vRPC limit on the next call).
+ // 2. A CLOSING session waits for the server to finish the vRPC before sending
+ // CloseSessionRequest (sending CloseSessionRequest while a vRPC is still active on
+ // the server is treated as a blackholed-response error by the server).
+ // Note: the caller's operation future is already completed immediately by
+ // RetryingVRpc.cancel().
if (currentRpc != null && rpcId == currentRpc.rpcId) {
currentCancel =
VRpcResult.createRejectedError(
@@ -556,6 +571,15 @@ private void cancelCloseTimeout() {
// Runs on sessionSyncContext (dispatched from the wheel-timer tick body). If the session is
// still in WAIT_SERVER_CLOSE, the server never closed the stream after CloseSessionRequest;
// force-close so the session reaches CLOSED.
+ //
+ // Why this only covers WAIT_SERVER_CLOSE and not CLOSING:
+ // - A session only stays in CLOSING while currentRpc != null (an active vRPC is on the wire).
+ // - Server invariant: the server only sends HeartbeatResponse while a vRPC is actively
+ // running on the server, and enforces VirtualRpcRequest.deadline on every vRPC.
+ // - Therefore, CLOSING is already bounded by checkHeartbeat() (if the server dies/stalls) and
+ // the server-enforced vRPC deadline (if the server is alive and sending heartbeats).
+ // - Once the vRPC finishes and the session enters WAIT_SERVER_CLOSE, no vRPC is active so
+ // server heartbeats stop, and closeTimeout takes over to bound waiting for stream closure.
private void checkCloseTimeout() {
sessionSyncContext.throwIfNotInThisSynchronizationContext();
if (state != SessionState.WAIT_SERVER_CLOSE) {