Skip to content

Commit 2e2cd0b

Browse files
committed
fix(bigtable): release session-creation budget on GO_AWAY during STARTING
A session that received a server GO_AWAY (or was force-closed) while still STARTING transitions STARTING -> CLOSING -> WAIT_SERVER_CLOSE and closes with prevState == WAIT_SERVER_CLOSE. The only failure-side budget release in onSessionClose lived inside the abnormal-close branch (if prevState != WAIT_SERVER_CLOSE), guarded by prevState == STARTING, so that path was skipped and the reserved concurrency slot leaked permanently. Over time this exhausts the session-creation budget even at very low QPS. Track each reservation on the SessionHandle and release it exactly once: as a success when the session reaches READY (onSessionReady), or as a failure when it terminates without ever becoming READY (onSessionClose), before the pool-state and abnormal-close gates. Keying off the reservation instead of the close-time prevState makes the release exactly-once and also covers the latent leak where onSessionReady early-returns (poolState != STARTED) without releasing. Adds a regression test that drives a session receiving a GO_AWAY before the open handshake, backed by a new go_away_before_open fake-proto field and matching SessionHandler branch. The test fails without the fix and passes with it.
1 parent d9c4df6 commit 2e2cd0b

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,18 @@ private enum PoolState {
154154
@GuardedBy("this")
155155
private final SessionCreationBudget budget;
156156

157+
// Handles that reserved a session-creation budget slot in createSession() and have not yet
158+
// released it. A slot is released exactly once: as a success when the session reaches READY
159+
// (onSessionReady), or as a failure when the session terminates without ever becoming READY
160+
// (onSessionClose). Tracking the reservation on the handle -- instead of inferring it from the
161+
// close-time prevState -- is what makes the release exactly-once: a session that goes
162+
// STARTING -> WAIT_SERVER_CLOSE (e.g. a server GO_AWAY before the open handshake completes)
163+
// closes with prevState == WAIT_SERVER_CLOSE, which the abnormal-close branch skips, so the
164+
// prevState == STARTING check alone would leak the slot forever. SessionHandle uses identity
165+
// equality, so a plain HashSet keys on the handle instance.
166+
@GuardedBy("this")
167+
private final Set<SessionHandle> sessionsHoldingBudget = new HashSet<>();
168+
157169
private final ClientConfigurationManager configManager;
158170
private final ClientConfigurationManager.ListenerHandle configListenerHandle;
159171

@@ -424,6 +436,9 @@ private synchronized void createSession(OpenParams openParams) {
424436
SessionStream stream = factory.createNew();
425437
Session session = new SessionImpl(metrics, info, sessionNum++, stream, timer);
426438
SessionHandle handle = sessions.newHandle(session);
439+
// Bind the budget reservation made by tryReserveSession() above to this handle so it is
440+
// released exactly once when the session becomes READY or terminates.
441+
sessionsHoldingBudget.add(handle);
427442

428443
Metadata localMd = new Metadata();
429444
localMd.merge(openParams.metadata());
@@ -498,7 +513,11 @@ private synchronized void onSessionReady(SessionHandle handle, OpenSessionRespon
498513
}
499514
handle.onSessionStarted();
500515

501-
budget.onSessionCreationSuccess();
516+
// Release the reservation as a success. Guarded by the set so we release exactly once even if
517+
// onSessionReady were ever delivered more than once.
518+
if (sessionsHoldingBudget.remove(handle)) {
519+
budget.onSessionCreationSuccess();
520+
}
502521

503522
// handle pending rpcs
504523
tryDrainPendingRpcs();
@@ -558,6 +577,17 @@ private void onSessionClose(
558577

559578
handle.onSessionClosed(prevState);
560579

580+
// Release the budget reservation as a failure if this session still holds one, i.e. it
581+
// terminated without ever reaching READY. Done here -- before the pool-state and
582+
// abnormal-close gates below -- because a session that received a GO_AWAY / was force-closed
583+
// while STARTING closes with prevState == WAIT_SERVER_CLOSE, which the abnormal-close branch
584+
// skips; keying off the reservation instead of prevState is what prevents the slot from
585+
// leaking. Sessions that reached READY were already removed from the set in onSessionReady,
586+
// so this never double-releases.
587+
if (sessionsHoldingBudget.remove(handle)) {
588+
budget.onSessionCreationFailure();
589+
}
590+
561591
// If the pool is closed then there is nothing else to do
562592
// dont need to create a replacement session and pending vRpcs get cleaned up in close()
563593
if (poolState == PoolState.CLOSED) {
@@ -582,9 +612,8 @@ private void onSessionClose(
582612
toBeClosed = popClosableRpcs();
583613
}
584614

585-
if (prevState == SessionState.STARTING) {
586-
budget.onSessionCreationFailure();
587-
}
615+
// Budget release for STARTING-phase closes is handled above via sessionsHoldingBudget,
616+
// which also covers the STARTING -> WAIT_SERVER_CLOSE (GO_AWAY) path this branch skips.
588617

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

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,108 @@ public void test() throws Exception {
612612
}
613613
}
614614

615+
@Nested
616+
class GoAwayBeforeReadyBudgetRelease {
617+
618+
private FakeClock fakeClock;
619+
private BigtableTimer mockTimer;
620+
private FakeSessionService fakeService;
621+
private Server server;
622+
private ChannelPool channelPool;
623+
private SessionPoolImpl<OpenFakeSessionRequest> sessionPool;
624+
private SessionCreationBudget budget;
625+
626+
private final Duration penalty = Duration.ofMinutes(1);
627+
628+
@BeforeEach
629+
void setUp() throws Exception {
630+
fakeClock = new FakeClock(Instant.now());
631+
// Mock timer so the pool's watchdog / AFE-prune / create-session-retry ticks never fire on
632+
// their own -- the budget stays quiescent after the session lifecycle completes, leaving the
633+
// test thread as the only actor that touches it.
634+
mockTimer = mock(BigtableTimer.class, Mockito.RETURNS_DEEP_STUBS);
635+
636+
// Budget of 1 so exactly one session goes STARTING; any extra create attempts fail to reserve
637+
// and just schedule a (no-op) retry on the mock timer.
638+
budget = new SessionCreationBudget(1, penalty, fakeClock);
639+
640+
fakeService = new FakeSessionService(executor);
641+
server = FakeServiceBuilder.create(fakeService).intercept(new PeerInfoInterceptor()).start();
642+
643+
channelPool =
644+
new SingleChannelPool(
645+
Suppliers.ofInstance(
646+
Grpc.newChannelBuilderForAddress(
647+
"localhost", server.getPort(), InsecureChannelCredentials.create())
648+
.build()));
649+
channelPool.start();
650+
651+
sessionPool =
652+
new SessionPoolImpl<>(
653+
metrics,
654+
FeatureFlags.getDefaultInstance(),
655+
CLIENT_INFO,
656+
configManager,
657+
channelPool,
658+
CallOptions.DEFAULT,
659+
FakeDescriptor.FAKE_SESSION,
660+
"fake-pool",
661+
mockTimer,
662+
MoreExecutors.directExecutor(),
663+
budget);
664+
}
665+
666+
@AfterEach
667+
void tearDown() {
668+
sessionPool.close(
669+
CloseSessionRequest.newBuilder()
670+
.setReason(CloseSessionRequest.CloseSessionReason.CLOSE_SESSION_REASON_USER)
671+
.setDescription("close session")
672+
.build());
673+
channelPool.close();
674+
server.shutdownNow();
675+
}
676+
677+
@Test
678+
void goAwayWhileStartingReleasesBudget() throws Exception {
679+
// Drive a single session that receives a GO_AWAY before the open handshake, so it transitions
680+
// STARTING -> WAIT_SERVER_CLOSE and closes with prevState == WAIT_SERVER_CLOSE. The
681+
// abnormal-close branch (prevState != WAIT_SERVER_CLOSE) is skipped, so before the fix the
682+
// STARTING budget slot -- which was released only on the prevState == STARTING path -- leaked
683+
// permanently. The fix keys the release off the reservation set, releasing it here as a
684+
// creation failure.
685+
sessionPool.start(
686+
OpenFakeSessionRequest.newBuilder().setGoAwayBeforeOpen(true).build(), new Metadata());
687+
688+
// Poll for the leaked slot to become reclaimable. onSessionClose(WAIT_SERVER_CLOSE) records a
689+
// creation failure (fix only); advancing past the penalty lets tryReserveSession drain it and
690+
// succeed. Access is guarded by the pool monitor (the pool's synchronized methods lock on the
691+
// pool instance) so it doesn't race the session-close callback that mutates the budget. With
692+
// the bug no slot is ever released and this stays false until the deadline.
693+
boolean recovered = false;
694+
long deadlineMs = System.currentTimeMillis() + 5_000;
695+
while (System.currentTimeMillis() < deadlineMs) {
696+
synchronized (sessionPool) {
697+
fakeClock.increment(penalty.plusMillis(1));
698+
if (budget.tryReserveSession()) {
699+
recovered = true;
700+
break;
701+
}
702+
}
703+
Thread.sleep(20);
704+
}
705+
706+
assertThat(recovered).isTrue();
707+
708+
// Sanity check that the client actually walked the GO_AWAY-before-open path: on receiving the
709+
// GO_AWAY while STARTING it moves to WAIT_SERVER_CLOSE and sends a CloseSession.
710+
boolean sentClose =
711+
fakeService.getSessionRequests().stream()
712+
.anyMatch(r -> r.getPayloadCase() == SessionRequest.PayloadCase.CLOSE_SESSION);
713+
assertThat(sentClose).isTrue();
714+
}
715+
}
716+
615717
@Test
616718
public void refreshConfigTest() throws Exception {
617719
OpenSessionRequest refreshRequest =

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,23 @@ public State onMessage(SessionRequest sReq) {
141141
throw new RuntimeException(e);
142142
}
143143

144+
if (oReq.getGoAwayBeforeOpen()) {
145+
// Send a GO_AWAY before the SessionParameters / OpenSession handshake, so the client
146+
// receives it while the session is still STARTING. The client will transition to
147+
// WAIT_SERVER_CLOSE, send a CloseSession, and half-close; RunningState terminates the
148+
// stream successfully on either of those. goAwayDelay is left large so RunningState's own
149+
// lifecycle GO_AWAY timer never fires during the test.
150+
handler.writeResponse(
151+
SessionResponse.newBuilder()
152+
.setGoAway(
153+
GoAwayResponse.newBuilder()
154+
.setReason("session_expiry")
155+
.setDescription("go away before open handshake")
156+
.build())
157+
.build());
158+
return new RunningState(handler, Duration.ofMinutes(1), oReq);
159+
}
160+
144161
if (oReq.hasStreamError()) {
145162
Status status =
146163
Status.fromCodeValue(oReq.getStreamError().getStatus().getCode())

java-bigtable/google-cloud-bigtable/src/test/proto/google/bigtable/v2/fake.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ message OpenFakeSessionRequest {
3737
StreamError stream_error = 3;
3838
SessionParametersResponse session_params = 4;
3939

40+
// If set, the server sends a GO_AWAY immediately without ever sending the
41+
// SessionParameters / OpenSession handshake responses, i.e. the session receives a
42+
// GO_AWAY while it is still STARTING. Used to exercise the STARTING -> WAIT_SERVER_CLOSE
43+
// session-creation-budget release path.
44+
bool go_away_before_open = 9;
45+
4046
// session steady phase
4147
message Action {
4248
google.protobuf.Duration delay = 1;

0 commit comments

Comments
 (0)