Threading refactor (5/7): Close ordering & shutdown hardening#5
Threading refactor (5/7): Close ordering & shutdown hardening#5igorbernstein2 wants to merge 9 commits into
Conversation
6a94038 to
49dd066
Compare
64d0864 to
bb3fd91
Compare
nimf
left a comment
There was a problem hiding this comment.
It's funny, reading the tests was more helpful for understanding this than reading comments.
| @Test | ||
| void awaitTerminatedReturnsTrueAfterSessionsDrain() | ||
| throws InterruptedException, ExecutionException, TimeoutException { | ||
| // Start a real session, issue + complete a vRPC so the session is fully open, then close |
There was a problem hiding this comment.
maybe also worth adding a test case when pool close is issued while vrpc is in-flight.
| private static final Logger logger = Logger.getLogger(Client.class.getName()); | ||
|
|
||
| // Per-pool drain budget during close. One full watchdog tick (5 min) plus 1 min buffer; if a | ||
| // pool can't drain in that window, something is genuinely wrong on the server side and we give |
There was a problem hiding this comment.
Hmmm.. I don't understand this comment
There was a problem hiding this comment.
The primary focus of this PR is to ensure that all of the async work is done before close the user executor (to make sure that all user notifications have a delivery path). The watchdog is the thing that prevent sessions from hanging indefinitely. Not sure how to re-word this
| () -> | ||
| otelContext | ||
| .wrap(() -> onStateChange(new Idle())) | ||
| .wrap(this::onTimerFired) |
There was a problem hiding this comment.
I forgot.. what is this timeout timer for?
There was a problem hiding this comment.
its the expoential backoff timer. ie the delay between attempts
|
|
||
| // Invoked from BigtableTimer.stop on the close thread. Trampoline back to the op executor so | ||
| // currentState reads and onStateChange are still single-threaded with the rest of the chain. | ||
| private void onTimerStopping() { |
There was a problem hiding this comment.
Hmm I don't fully understand the stop hook. Is the timer stooped when it fired or cancelled? So this hook is called on timer fired and on cancel? So we unregister it in both places to clean up?
There was a problem hiding this comment.
stop hook is when the timer is being closed so that listeners that have scheduled work can be notified that their task will never run and clean up
|
|
||
| private void unregisterStopHook() { | ||
| if (stopHook != null) { | ||
| stopHook.unregister(); |
There was a problem hiding this comment.
what does unregister do? NettyWheelTimer already clears the hook from the map in onStop?
There was a problem hiding this comment.
Timer has 2 callbacks: to run the task when its scheduled and separate callback for being notified that the timer is being closed. stophook.unregister removes the 2nd callback
There was a problem hiding this comment.
we have 2 listeners here:
- for being notified when we are scheduled to run
- for being notified that the timer is being closed and 1 will never run.
They are registered separately so need to be removed separately
49dd066 to
de62fa1
Compare
bb3fd91 to
1d18231
Compare
…lient.close
Client.close shut down userCallbackExecutor before draining the SessionPools
that depend on it, so late listener.onClose tasks from in-flight RPCs
arrived after backing was dead and got RejectedExecutionException — silently
stranding the user's terminal callbacks. The earlier fix sprinkled
inline-drain fallbacks inside OpExecutor; restructure shutdown instead so
the race can't happen.
SessionPool gains awaitTerminated(Duration), backed by a CompletableFuture
SessionPoolImpl completes from onSessionClose once the pool is CLOSED and
the last session has drained. close() no longer kills the watchdog —
awaitTerminated takes ownership of that, so the watchdog stays alive during
shutdown and can escalate any session stuck in WAIT_SERVER_CLOSE longer
than its tick interval (5 min) via forceClose.
Client.close becomes three explicit phases: (1) initiate graceful close on
each pool, (2) awaitTerminated on each with a 6-minute per-pool budget
(one full watchdog tick plus buffer), (3) tear down userCallbackExecutor /
channelPool / timers in the existing order, now safely because all
listener.onClose tasks are queued or drained before backing dies.
VOperationImpl.start queues grpcContext.addListener through the op executor
via runInline. Without this, an async-queued onClose from chain.start
(PendingVRpc pool-closed fast-fail, VRpcImpl deadline-exceeded short-circuit)
could drain between the CleanupListener.closed read and addListener:
CleanupListener.onClose would call removeListener as a no-op pre-registration,
and the caller would then register a listener with nothing to remove it.
The leak is per-RPC and permanent until grpcContext cancels — for long-lived
application contexts it accumulates indefinitely. FIFO ordering through the
op executor makes the closed-check sound: any onClose chain.start enqueued
drains first, so the check is accurate by the time we evaluate it.
Add a `closed` AtomicBoolean + checkNotClosed() guard on the three openers
so concurrent opens during shutdown can't create pools the close path
won't see. close() is now idempotent via CAS on that flag.
Tests:
- ClientTest#openAfterCloseThrows / closeIsIdempotent
- SessionPoolImplTest awaitTerminated* coverage
- VOperationImplTest covering async onClose / context cancel ordering
- SessionPoolImplTest tearDown now calls awaitTerminated so the watchdog
is closed before testTimer.stop races its self-reschedule
- FakeSessionPool in TableBaseTest gains a no-op awaitTerminated stub
Drive-by: remove the spurious @nested annotation from SessionPoolImplTest's
top-level class. @nested is meaningful only on non-static inner classes;
on the outer class it caused Surefire to mis-attribute test counts.
…close Scheduled RetryingVRpcs hold no session reference, so a long-delay retry (server-driven RetryInfo.retryDelay) outlives Phase 2 drain and is silently discarded when sessionTimer.stop() runs in Phase 3. The user's listener never fires. Add an onStop hook primitive to BigtableTimer. Scheduled.onStart registers a hook on entry and unregisters on every exit path (normal fire, cancel, hook fire). NettyWheelTimer.stop() runs every hook synchronously before discarding pending wheel timeouts; hooks trampoline back through the op executor to drive Scheduled to a CANCELLED Done. Reorder Client.close Phase 3 so sessionTimer.stop() runs before userCallbackExecutor.close(), giving the hook-fired onClose tasks a live op-executor backing to land on. Also replace Scheduled.onStart's dead RejectedExecutionException catch with IllegalStateException, matching BigtableTimer.stop()'s documented post-condition.
openTableAsync / openAuthorizedViewAsync / openMaterializedViewAsync read 'closed' lock-free, then constructed the pool, then inserted it into sessionPools — close() could CAS closed=true and snapshot sessionPools in between, leaving the new pool orphaned: never closed, its callbacks landing on shut-down executors. Hold sessionPools' monitor across the closed check, the construction, and the insert. Opens are infrequent (typically once per table at app startup) so the monitor cost is negligible. Move close()'s closed flip inside the same monitor too. With every access now under the lock, downgrade 'closed' from AtomicBoolean to a plain boolean — the CAS provided no value over a plain read+write under the lock.
ShimImpl registered its userCallbackExecutor with a bare shutdown() closer, while Client.create's path uses shutdownAndAwait (which gives in-flight listener.onClose tasks a 5-second drain window before shutdownNow). On ShimImpl close, queued callbacks were abandoned mid-flight — fine for quiescent shutdowns but a regression for fast-close patterns (test boundaries, dynamic config reloads) where in-flight callbacks have not yet drained. Promote Client.shutdownAndAwait from private-static to public-static so ShimImpl (different package) can reuse the same shutdown semantics, and update ShimImpl to call it.
ReadRowShim and MutateRowShim cache per-target handles in a Guava LoadingCache whose loader calls Client.openTableAsync (and friends). After the Client-close hardening, that loader throws IllegalStateException post-close — and getUnchecked wraps it in UncheckedExecutionException, so callers see an unchecked exception thrown out of readRow / mutateRow instead of a failed CompletableFuture as the surface contract promises. Introduce SessionPoolMap, a small wrapper over the existing Util.createSessionMap cache that owns the conversion: get() unwraps UncheckedExecutionException to surface the original cause, and apply() converts a loader throw into a failed CompletableFuture for the async call paths. Replace the inline LoadingCache fields in both shim ops files. Covered by a new focused unit test.
The existing awaitTerminated tests only close an empty pool, close after the vRPC has completed, or issue the vRPC against an already-closed pool — none of them exercise SessionPoolImpl.close()'s pendingRpcs cancellation loop with a non-empty toCancel list. Add closeCancelsInFlightVRpcAndDrains: park the session-open response in a DelayedClientInterceptor so the vRPC sits in pendingRpcs, then close the pool and verify the listener gets a single CANCELLED onClose with the "SessionPool closed:" description, awaitTerminated returns true via the OPENING session draining through sessions.close(req), and no phantom second onClose arrives once the delayed responses flush.
…ailure The catch branch in awaitTerminated is reachable only if drainedFuture is ever completed exceptionally or cancelled — neither happens today, but if it ever does we should leave a telemetry breadcrumb instead of just an uncaught IllegalStateException for the caller to puzzle over. Mirror the precondition-failure pattern used elsewhere in this file (record at ERROR, then throw).
1d18231 to
d824fd4
Compare
b82a480 to
8f643ad
Compare
`apply` already converted lookup throws to failed futures, but a sync throw from `op.apply(v)` itself (NPE on malformed input, REE during shutdown, any RuntimeException from the wrapped call chain) propagated to the caller — contradicting the docstring's contract that "callers consistently observe failures through the future surface." Wrap the op invocation in try/catch with the same pattern as the lookup arm.
Summary
Phase 5 of 7. The largest phase — six fixes flushed out by the new threading model, all around
Client.close/ shutdown ordering and race conditions.The big one is the
Client.closerestructure (3 explicit phases: initiate close → await termination → tear down infrastructure). The rest are targeted hardening of specific races.What's in here
fix: drain SessionPools before tearing down userCallbackExecutor on Client.close(+ addListener serialize, Duration cleanup)SessionPoolgainsawaitTerminated(Duration).Client.closebecomes three explicitawaitTerminatedper pool (6-min budget), tear down. Plus:VOperationImpl.startqueuesgrpcContext.addListenerthrough the op executor viarunInline— without this, anonClosecould drain between the closed-check andaddListener, leaking the listener per-RPC for the lifetime ofgrpcContext. PlusclosedAtomicBoolean + idempotentclose().fix: deliver terminal onClose to Scheduled retries pending at Client.closeBigtableTimergains anonStophook.Scheduled.onStartregisters a hook on entry;NettyWheelTimer.stop()runs every hook synchronouslyClient.closereordered sosessionTimer.stop()runs beforeuserCallbackExecutor.close(), giving the hook-firedonClosetasks a live op-executor to land on.Scheduledretries (server-drivenRetryInfo.retryDelay) outlived Phase 2 and were silently discarded.fix: serialize open*/close so racing opens cannot create orphan pools(+ supersedes earlier sessionPools sync)open*/closesites onClientnow holdsessionPools' monitor across theclosedcheck,closeddowngrades to a plainboolean(CAS provided no value under the monitor). Prevents an orphan pool — created afterclose()snapshottedsessionPools— from outliving the closefix: ShimImpl uses shutdownAndAwait for userCallbackExecutorShimImplregistered itsuserCallbackExecutorwith a bareshutdown()closer, whileClient.create's path usesshutdownAndAwait(5s drain forClient.shutdownAndAwaitfrom private-static to public-static soShimImplcan reuse the same semantics.fix: stop heartbeat-driven force-close once session enters CLOSINGCLOSINGis on a terminal trajectory; a missed heartbeat there reclassifiescloseReasonfromUSERtoMISSED_HEARTBEATand pollutesSessionPool.awaitTerminated/Client.POOL_DRAIN_TIMEOUT. Also cancelheartbeatTimeouton entry toCLOSING(was:WAIT_SERVER_CLOSE).fix: lift shim loader throws to failed futures via SessionPoolMapClient.closehardening, the shim loader (ReadRowShim/MutateRowShim) throwsIllegalStateExceptionpost-close;getUncheckedwrapsUncheckedExecutionException, surfacing an unchecked throw out ofreadRow/mutateRowinstead of the failedCompletableFuturethe async API contract promises. NewSessionPoolMapwrapper unwraps the wrapperget()and converts loader throws into failed futures forapply().Stack position
threading-refactor-phase-4threading-refactor-phase-6— tracer correctness &chain.isDone()