Skip to content

[fix][client] Fix waiting lookup requests not dispatched on timeout in ClientCnx#26143

Open
geniusjoe wants to merge 1 commit into
apache:masterfrom
geniusjoe:dev/lookup-timeout
Open

[fix][client] Fix waiting lookup requests not dispatched on timeout in ClientCnx#26143
geniusjoe wants to merge 1 commit into
apache:masterfrom
geniusjoe:dev/lookup-timeout

Conversation

@geniusjoe

Copy link
Copy Markdown
Contributor

main pr #25038

Motivation

PR #25038 fixed the semaphore leak issue by adding a TimeoutException check in the whenComplete callback of newLookup(). However, it did not fully address the waiting queue starvation problem.

When a lookup request times out in checkRequestTimeout(), the code only calls pendingRequests.remove() and relies on the whenComplete callback to release the semaphore. While the semaphore is correctly released, getAndRemovePendingLookupRequest() — which is responsible for polling and dispatching the next waiting request from waitingLookupRequests — is never invoked on the timeout path.

This means: if all concurrent lookup slots time out simultaneously (e.g., during a broker GC pause or network partition), the waiting requests remain stuck indefinitely even though semaphore permits become available. They will never be dispatched until a new non-timeout lookup response arrives.

Modifications

Optimized the timeout handling path in ClientCnx.java:

  1. checkRequestTimeout(): For Lookup-type requests, use getAndRemovePendingLookupRequest() instead of plain pendingRequests.remove(). This ensures the next waiting request in waitingLookupRequests is polled and dispatched upon timeout, rather than just releasing the semaphore.

  2. newLookup() whenComplete callback: Removed the TimeoutException check since getAndRemovePendingLookupRequest() already handles semaphore release and queue driving. Keeping it would cause a double-release.

Verifying this change

This change added tests and can be verified as follows:

  • testLookupTimeoutReleasesSemaphore — verifies that the semaphore is properly released after a lookup timeout.
  • testLookupTimeoutDrivesWaitingQueue — verifies that when a concurrent lookup times out, the next waiting request in the queue is dispatched.
  • testMultipleLookupTimeoutsNoSemaphoreLeak — verifies that with multiple concurrent and queued lookups all timing out, every request eventually completes and no semaphore permits are leaked.

Does this pull request potentially affect one of the following parts:

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

Local workflow:

geniusjoe#1

@geniusjoe

Copy link
Copy Markdown
Contributor Author

@congbobo184
Hi! Since you're familiar with this area from #25038, would you mind taking a look at this PR when you have a chance? It optimizes the timeout path to also drive the waiting lookup queue. Thanks in advance

@Denovo1998 Denovo1998 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's best to let the permit ownership follow the active entry in pendingRequests:

  • Only the path that successfully removes the active lookup from pendingRequests has the right to release or transfer the permit.

  • Timeout, lookup response, write failure, CommandError, and channel close all go through the same lookup cleanup primitive.

  • The helper uses remove(requestId, expectedFuture) and returns whether ownership was successfully obtained.

  • No longer guess whether the permit needs to be released based on the type of future exception.

  • Add permit invariant tests for failed lookup response, active request connection close, and promoted waiting request connection close.

This might resolve timeout starvation, double-release, and the unclear permit ownership issue for promoted waiting requests on certain exception paths at once.

Comment on lines 1009 to 1012
future.whenComplete((lookupDataResult, throwable) -> {
if (throwable instanceof ConnectException
|| throwable instanceof PulsarClientException.LookupException
|| FutureUtil.unwrapCompletionException(throwable) instanceof TimeoutException) {
|| throwable instanceof PulsarClientException.LookupException) {
pendingLookupRequestSemaphore.release();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAndRemovePendingLookupRequest() has already released or transferred the active lookup permit before handleLookupResponse() completes a failed response with LookupException. This callback then releases the same permit again. A failed CommandLookupTopicResponse increases availablePermits() from 5000 to 5001 in a minimal test. Could we centralize permit release/transfer in the successful pending-map removal path instead of inferring ownership from the exception type?

Comment on lines 1795 to 1807
TimedCompletableFuture<?> requestFuture = pendingRequests.get(request.requestId);
if (requestFuture != null
&& !requestFuture.hasGotResponse()) {
pendingRequests.remove(request.requestId, requestFuture);
if (request.requestType == RequestType.Lookup) {
// For Lookup type, use getAndRemovePendingLookupRequest to release the semaphore
// and drive the waiting queue
getAndRemovePendingLookupRequest(request.requestId);
} else {
pendingRequests.remove(request.requestId, requestFuture);
}
if (!requestFuture.isDone()) {
String timeoutMessage = request.requestType.getDescription() + " timeout";
if (requestFuture.completeExceptionally(new TimeoutException(timeoutMessage))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this path use an expected-future removal and only complete the timeout when it actually wins the removal race? At present we get() the future, call an unconditional remove-by-id helper, ignore its result, and may still complete the old reference with a timeout after another path removed it. The helper also increments duplicatedResponseCounter when this race is lost, although no duplicate response was received.

assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), initialPermits);
});

eventLoop.shutdownGracefully();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please wrap the event loop lifetime in try/finally so it is also shut down when an assertion or timed get() fails. It would also be safer to wait for graceful shutdown to complete; otherwise these timeout tests can leave threads behind and contaminate later tests.
Those below are also needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants