Skip to content

Commit d586d07

Browse files
authored
fix(bigtable): fix session creation leaks (#13887)
Fix budget leaks: 1. GO_AWAY before open handshake — session goes STARTING → WAIT_SERVER_CLOSE and closes with prevState == WAIT_SERVER_CLOSE, so the release never fired. 2. Synchronous failure in createSession — factory.createNew()/constructor/metadata-merge throws before any listener is wired, so no terminal callback ever runs to release the slot. 3. Session wedged in STARTING forever — a stream that connects but never sends OpenSession or a GO_AWAY leaves the session stuck with no terminal callback.
1 parent dc80fe8 commit d586d07

7 files changed

Lines changed: 494 additions & 15 deletions

File tree

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImpl.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ public class SessionImpl implements Session, VRpcSessionApi {
7878
.setDescription("missed heartbeat")
7979
.build();
8080

81+
@VisibleForTesting
82+
// Upper bound on how long a session may remain STARTING before we give up on the open handshake
83+
// and force-close it. Without this, a stream that connects but never delivers the OpenSession
84+
// response (or a GoAway) leaves the session wedged in STARTING forever, holding the
85+
// session-creation-budget slot it reserved and never firing the terminal callback that would
86+
// release it. Force-closing routes STARTING -> WAIT_SERVER_CLOSE -> onSessionClose, which frees
87+
// the budget.
88+
static final Duration OPEN_SESSION_TIMEOUT = Duration.ofSeconds(30);
89+
90+
private static final CloseSessionRequest OPEN_TIMEOUT_CLOSE_REQUEST =
91+
CloseSessionRequest.newBuilder()
92+
.setReason(CloseSessionReason.CLOSE_SESSION_REASON_ERROR)
93+
.setDescription("session open timeout")
94+
.build();
95+
8196
private final Clock clock;
8297
private final BigtableTimer timer;
8398
// Serializes all session state mutations. Stream callbacks and the heartbeat tick dispatch
@@ -130,6 +145,10 @@ public class SessionImpl implements Session, VRpcSessionApi {
130145
// transitions so the wheel doesn't carry a no-op entry until the next fire.
131146
@Nullable private BigtableTimer.Timeout heartbeatTimeout;
132147

148+
// Handle for the open-handshake deadline (armed while STARTING). Cancelled the moment the
149+
// session leaves STARTING so a session that opens normally never trips it.
150+
@Nullable private BigtableTimer.Timeout openTimeout;
151+
133152
// Set by the global SyncContext handler when an uncaught exception triggers an abort. Read on
134153
// re-entry to break out instead of looping. Only accessed inside sessionSyncContext.
135154
private boolean isAborting = false;
@@ -310,6 +329,9 @@ public void start(OpenSessionRequest req, Metadata headers, Listener sessionList
310329
tracer.onStart();
311330

312331
updateState(SessionState.STARTING);
332+
// Bound the open handshake: if we're still STARTING when this fires, force-close so the
333+
// reserved session-creation-budget slot is released instead of leaking forever.
334+
scheduleOpenTimeoutCheck();
313335
openParams = OpenParams.create(headers, req);
314336

315337
SessionRequest wrappedReq = SessionRequest.newBuilder().setOpenSession(req).build();
@@ -457,6 +479,41 @@ public void cancelRpc(long rpcId, @Nullable String message, @Nullable Throwable
457479
});
458480
}
459481

482+
private void scheduleOpenTimeoutCheck() {
483+
openTimeout =
484+
timer.newTimeout(
485+
this::checkOpenTimeout,
486+
sessionSyncContext,
487+
OPEN_SESSION_TIMEOUT.toMillis(),
488+
TimeUnit.MILLISECONDS);
489+
}
490+
491+
private void cancelOpenTimeout() {
492+
if (openTimeout != null) {
493+
openTimeout.cancel();
494+
openTimeout = null;
495+
}
496+
}
497+
498+
// Runs on sessionSyncContext (dispatched from the wheel-timer tick body). If the session is
499+
// still STARTING, the open handshake never completed; force-close so the reserved
500+
// session-creation-budget slot is released via the STARTING -> WAIT_SERVER_CLOSE ->
501+
// onSessionClose
502+
// path. Any transition out of STARTING cancels this timer, so reaching here in another state is a
503+
// benign race (tick already dispatched) and is ignored.
504+
private void checkOpenTimeout() {
505+
sessionSyncContext.throwIfNotInThisSynchronizationContext();
506+
if (state != SessionState.STARTING) {
507+
return;
508+
}
509+
logger.warning(
510+
String.format(
511+
"Session %s did not complete the open handshake within %s, forcing session close",
512+
info.getLogName(), OPEN_SESSION_TIMEOUT));
513+
debugTagTracer.record(TelemetryConfiguration.Level.WARN, "session_open_timeout");
514+
forceClose(OPEN_TIMEOUT_CLOSE_REQUEST);
515+
}
516+
460517
private void scheduleHeartbeatCheck() {
461518
heartbeatTimeout =
462519
timer.newTimeout(
@@ -849,6 +906,12 @@ private PeerInfo safeGetPeerInfo() {
849906
private void updateState(SessionState newState) {
850907
this.state = newState;
851908
this.lastStateChangedAt = clock.instant();
909+
// The open deadline only applies while STARTING. Any other transition (READY on success, or a
910+
// terminal state) means the handshake resolved, so drop the pending tick. NEW -> STARTING keeps
911+
// it, since start() arms the timer right after this call.
912+
if (newState != SessionState.STARTING) {
913+
cancelOpenTimeout();
914+
}
852915
// Once we're past READY, no further heartbeat checks are useful: checkHeartbeat short-circuits
853916
// on state.phase >= WAIT_SERVER_CLOSE. Cancel any pending tick to keep the wheel clean during
854917
// session churn.

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionList.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ SessionHandle newHandle(Session session) {
7777
return h;
7878
}
7979

80+
/**
81+
* Unwinds a handle produced by {@link #newHandle} whose session never started, i.e. a synchronous
82+
* failure in {@code SessionPoolImpl.createSession} between {@code newHandle} and {@code
83+
* session.start}. Such a session stays in {@link SessionState#NEW}, so it never receives a
84+
* terminal {@link SessionHandle#onSessionClosed} callback (which additionally rejects NEW
85+
* sessions). This reverses exactly the bookkeeping {@code newHandle} performed so the handle
86+
* doesn't strand {@link #allSessions} (blocking pool drain) or skew the pool stats.
87+
*/
88+
void removeUnstartedHandle(SessionHandle handle) {
89+
if (allSessions.remove(handle)) {
90+
poolStats.startingCount--;
91+
if (handle.inExpectedCount) {
92+
poolStats.expectedCapacity--;
93+
handle.inExpectedCount = false;
94+
}
95+
}
96+
}
97+
8098
/** Get {@link PoolStats} */
8199
PoolStats getStats() {
82100
return poolStats;

java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/session/SessionPoolImpl.java

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ private enum PoolState {
175175
@GuardedBy("poolLock")
176176
private final SessionCreationBudget budget;
177177

178+
// Handles that reserved a session-creation budget slot in createSession() and have not yet
179+
// released it. A slot is released exactly once: as a success when the session reaches READY
180+
// (onSessionReady), or as a failure when the session terminates without ever becoming READY
181+
// (onSessionClose). Tracking the reservation on the handle -- instead of inferring it from the
182+
// close-time prevState -- is what makes the release exactly-once: a session that goes
183+
// STARTING -> WAIT_SERVER_CLOSE (e.g. a server GO_AWAY before the open handshake completes)
184+
// closes with prevState == WAIT_SERVER_CLOSE, which the abnormal-close branch skips, so the
185+
// prevState == STARTING check alone would leak the slot forever. SessionHandle uses identity
186+
// equality, so a plain HashSet keys on the handle instance.
187+
@GuardedBy("poolLock")
188+
private final Set<SessionHandle> sessionsHoldingBudget = new HashSet<>();
189+
178190
private final ClientConfigurationManager configManager;
179191
private final ClientConfigurationManager.ListenerHandle configListenerHandle;
180192

@@ -455,35 +467,65 @@ private void createSession(OpenParams openParams) {
455467
// Explicit create session streams in a detached context
456468
// We don't want to propagate the rpc deadline nor the trace context
457469
Context prevContext = Context.ROOT.attach();
470+
// Tracks the handle once registered so the catch below can tell whether the reservation was
471+
// ever bound to a handle (and therefore what cleanup is required).
472+
SessionHandle handle = null;
458473
try {
459474
try (Scope ignored = io.opentelemetry.context.Context.root().makeCurrent()) {
460475

476+
// Build the metadata before registering the handle so the only step between newHandle and
477+
// session.start (whose own failures are handled by the session's abort path once the
478+
// listener is published) is the non-throwing sessionsHoldingBudget.add.
479+
Metadata localMd = new Metadata();
480+
localMd.merge(openParams.metadata());
481+
461482
SessionStream stream = factory.createNew();
462483
Session session = new SessionImpl(metrics, info, sessionNum++, stream, timer);
463-
SessionHandle handle = sessions.newHandle(session);
484+
handle = sessions.newHandle(session);
485+
// Bind the budget reservation made by tryReserveSession() above to this handle so it is
486+
// released exactly once when the session becomes READY or terminates.
487+
sessionsHoldingBudget.add(handle);
464488

465-
Metadata localMd = new Metadata();
466-
localMd.merge(openParams.metadata());
489+
final SessionHandle startedHandle = handle;
467490
session.start(
468491
openParams.request(),
469492
localMd,
470493
new Listener() {
471494
@Override
472495
public void onReady(OpenSessionResponse msg) {
473-
SessionPoolImpl.this.onSessionReady(handle, msg);
496+
SessionPoolImpl.this.onSessionReady(startedHandle, msg);
474497
}
475498

476499
@Override
477500
public void onGoAway(GoAwayResponse msg) {
478-
SessionPoolImpl.this.onSessionGoAway(handle, msg);
501+
SessionPoolImpl.this.onSessionGoAway(startedHandle, msg);
479502
}
480503

481504
@Override
482505
public void onClose(SessionState prevState, Status status, Metadata trailers) {
483-
SessionPoolImpl.this.onSessionClose(handle, prevState, status, trailers);
506+
SessionPoolImpl.this.onSessionClose(startedHandle, prevState, status, trailers);
484507
}
485508
});
486509
}
510+
} catch (RuntimeException | Error e) {
511+
// A synchronous failure here (e.g. factory.createNew, the SessionImpl constructor, or
512+
// metadata merge) means no terminal session callback will ever run for this reservation, so
513+
// the budget slot and any partially-registered handle would leak for the life of the pool.
514+
// Release the slot as a failure and unwind the handle ourselves. Note session.start's own
515+
// failures do NOT land here: it publishes the listener first and routes exceptions through
516+
// the session's abort path, which fires onClose -> onSessionClose (the normal release path).
517+
debugTagTracer.record(TelemetryConfiguration.Level.WARN, "session_create_failed");
518+
logger.log(Level.WARNING, "Failed to create session, releasing reserved budget", e);
519+
// If a handle was registered it owns the reservation via the set; otherwise the reservation
520+
// was never bound to a handle and must be released directly.
521+
if (handle == null || sessionsHoldingBudget.remove(handle)) {
522+
budget.onSessionCreationFailure();
523+
}
524+
if (handle != null) {
525+
sessions.removeUnstartedHandle(handle);
526+
}
527+
// Let the pool recover instead of running permanently short a session.
528+
maybeScheduleCreateSessionRetry();
487529
} finally {
488530
Context.ROOT.detach(prevContext);
489531
}
@@ -540,7 +582,11 @@ private void onSessionReady(SessionHandle handle, OpenSessionResponse ignored) {
540582
}
541583
handle.onSessionStarted();
542584

543-
budget.onSessionCreationSuccess();
585+
// Release the reservation as a success. Guarded by the set so we release exactly once even if
586+
// onSessionReady were ever delivered more than once.
587+
if (sessionsHoldingBudget.remove(handle)) {
588+
budget.onSessionCreationSuccess();
589+
}
544590

545591
// handle pending rpcs
546592
tryDrainPendingRpcs();
@@ -613,6 +659,21 @@ private void onSessionClose(
613659

614660
poolLock.lock();
615661
try {
662+
// Release the budget reservation FIRST, before any code below that can throw. This is the
663+
// session's terminal callback, so there is no later backstop: if the reservation is not
664+
// released here it leaks forever. In particular handle.onSessionClosed(prevState) below can
665+
// throw (e.g. IllegalStateException on an unexpected NEW / double-CLOSED prevState), which
666+
// would otherwise strand the slot -- the same leak this fix exists to prevent.
667+
//
668+
// Release as a failure if this session still holds a slot, i.e. it terminated without ever
669+
// reaching READY; sessions that reached READY were already removed from the set in
670+
// onSessionReady, so this never double-releases. Keying off the reservation instead of the
671+
// close-time prevState is also what covers the STARTING -> WAIT_SERVER_CLOSE (GO_AWAY) path
672+
// that the abnormal-close branch below skips.
673+
if (sessionsHoldingBudget.remove(handle)) {
674+
budget.onSessionCreationFailure();
675+
}
676+
616677
logger.fine(
617678
String.format("Removing closed session from pool %s", handle.getSession().getLogName()));
618679

@@ -642,9 +703,8 @@ private void onSessionClose(
642703
toBeClosed = popClosableRpcs();
643704
}
644705

645-
if (prevState == SessionState.STARTING) {
646-
budget.onSessionCreationFailure();
647-
}
706+
// Budget release for STARTING-phase closes is handled above via sessionsHoldingBudget,
707+
// which also covers the STARTING -> WAIT_SERVER_CLOSE (GO_AWAY) path this branch skips.
648708

649709
// TODO: backoff creating a new session when consecutive failures > max?
650710
if (poolSizer.handleSessionClose(StatusProto.fromStatusAndTrailers(status, trailers))) {

java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/session/SessionImplTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
import java.io.IOException;
7171
import java.time.Duration;
7272
import java.time.Instant;
73+
import java.util.ArrayList;
74+
import java.util.Collections;
75+
import java.util.List;
7376
import java.util.concurrent.CountDownLatch;
7477
import java.util.concurrent.ExecutionException;
7578
import java.util.concurrent.Executor;
@@ -596,6 +599,12 @@ void testHeartbeatNotScheduledWithoutVRpc() throws Exception {
596599
assertThat(sessionListener.popUntil(OpenSessionResponse.class))
597600
.isInstanceOf(OpenSessionResponse.class);
598601

602+
// The open-handshake deadline is armed while STARTING and cancelled once READY (before onReady,
603+
// so it's already settled here). Reset the counters to scope the assertion below to the
604+
// heartbeat lifecycle only.
605+
counting.scheduleCount.set(0);
606+
counting.cancelCount.set(0);
607+
599608
// After session is READY with no vRPC, no Timeout should ever have been scheduled. Wait a
600609
// bit so that any background tick (none expected) would have shown up.
601610
Thread.sleep(50);
@@ -640,6 +649,11 @@ void testHeartbeatScheduledOnlyDuringVRpc() throws Exception {
640649
assertThat(sessionListener.popUntil(OpenSessionResponse.class))
641650
.isInstanceOf(OpenSessionResponse.class);
642651

652+
// The open-handshake deadline is armed while STARTING and cancelled once READY. Reset the
653+
// counters so the assertions below track only the heartbeat lifecycle.
654+
counting.scheduleCount.set(0);
655+
counting.cancelCount.set(0);
656+
643657
assertThat(counting.scheduleCount.get()).isEqualTo(0);
644658

645659
VRpc<SessionFakeScriptedRequest, SessionFakeScriptedResponse> rpc =
@@ -811,8 +825,99 @@ public void onClose(Session.SessionState prevState, Status status, Metadata trai
811825
assertThat(session.getState()).isEqualTo(Session.SessionState.CLOSED);
812826
}
813827

828+
// Regression test: a session that connects but never completes the open handshake must not stay
829+
// wedged in STARTING forever. Without the open-handshake deadline the session would never fire a
830+
// terminal callback, so the pool's reserved session-creation-budget slot would leak. Arming
831+
// OPEN_SESSION_TIMEOUT force-closes such a session, routing STARTING -> WAIT_SERVER_CLOSE and
832+
// driving the listener to a terminal Status (which releases the budget in the pool).
833+
@Test
834+
void sessionOpenTimeoutForcesClose() throws Exception {
835+
// Capture the scheduled open-timeout tick so we can fire it deterministically instead of
836+
// waiting the real OPEN_SESSION_TIMEOUT.
837+
CapturingBigtableTimer capturing = new CapturingBigtableTimer(timer);
838+
SessionImpl session =
839+
new SessionImpl(metrics, poolInfo, 0, sessionFactory.createNew(), capturing);
840+
841+
FakeSessionListener sessionListener = new FakeSessionListener();
842+
session.start(
843+
OpenSessionRequest.newBuilder()
844+
.setPayload(
845+
OpenFakeSessionRequest.newBuilder().setHangBeforeOpen(true).build().toByteString())
846+
.build(),
847+
new Metadata(),
848+
sessionListener);
849+
850+
// start() runs on the sessionSyncContext; wait until it has armed the open-timeout tick and the
851+
// session is STARTING. The server never responds, so it stays STARTING.
852+
Stopwatch sw = Stopwatch.createStarted();
853+
while ((capturing.tasks.isEmpty() || session.getState() != Session.SessionState.STARTING)
854+
&& sw.elapsed(TimeUnit.SECONDS) < 5) {
855+
Thread.sleep(10);
856+
}
857+
assertThat(session.getState()).isEqualTo(Session.SessionState.STARTING);
858+
assertWithMessage("open-timeout tick should have been armed while STARTING")
859+
.that(capturing.tasks)
860+
.isNotEmpty();
861+
862+
// Fire the open-timeout tick on its executor (the sessionSyncContext), simulating the deadline
863+
// elapsing while still STARTING.
864+
capturing.executors.get(0).execute(capturing.tasks.get(0));
865+
866+
// The session must force-close and drive the listener to a terminal Status.
867+
assertWithMessage("terminal status should be delivered after open timeout")
868+
.that(sessionListener.popUntil(Status.class))
869+
.isNotNull();
870+
sw.reset().start();
871+
while (session.getState() != Session.SessionState.WAIT_SERVER_CLOSE
872+
&& session.getState() != Session.SessionState.CLOSED
873+
&& sw.elapsed(TimeUnit.SECONDS) < 5) {
874+
Thread.sleep(10);
875+
}
876+
assertThat(session.getState())
877+
.isAnyOf(Session.SessionState.WAIT_SERVER_CLOSE, Session.SessionState.CLOSED);
878+
}
879+
814880
// endregion
815881

882+
// Captures newTimeout tasks/executors so a scheduled tick can be fired deterministically instead
883+
// of waiting out the real delay.
884+
private static final class CapturingBigtableTimer implements BigtableTimer {
885+
private final BigtableTimer delegate;
886+
final List<Runnable> tasks = Collections.synchronizedList(new ArrayList<>());
887+
final List<Executor> executors = Collections.synchronizedList(new ArrayList<>());
888+
889+
CapturingBigtableTimer(BigtableTimer delegate) {
890+
this.delegate = delegate;
891+
}
892+
893+
@Override
894+
public Timeout newTimeout(Runnable task, Executor executor, long delay, TimeUnit unit) {
895+
tasks.add(task);
896+
executors.add(executor);
897+
return new Timeout() {
898+
@Override
899+
public boolean cancel() {
900+
return true;
901+
}
902+
903+
@Override
904+
public boolean isCancelled() {
905+
return false;
906+
}
907+
};
908+
}
909+
910+
@Override
911+
public Registration onStop(Runnable hook) {
912+
return delegate.onStop(hook);
913+
}
914+
915+
@Override
916+
public void stop() {
917+
delegate.stop();
918+
}
919+
}
920+
816921
// Wraps a real BigtableTimer and counts newTimeout / cancel calls. Used to assert that the
817922
// heartbeat tick is only armed while a vRPC is in flight.
818923
private static final class CountingBigtableTimer implements BigtableTimer {

0 commit comments

Comments
 (0)