Repository navigation
Conversation
…f never completing
After PulsarClient.close(), getPartitionsForTopic() returned a future that
never completed. Producer, consumer and reader creation fail at once with
AlreadyClosedException, but getPartitionedTopicMetadata(), which
getPartitionsForTopic() calls, had no state check: it sent a lookup on the
closed client, and that lookup never completes. Resolving the broker
address adds a listener to a promise of the client's event loop, which has
terminated, so Netty drops the notification ("Failed to submit a listener
notification task. Event loop shut down?").
A lookup in flight when the client closed was left pending too. Closing
the connection fails the request with a retriable ConnectException, so the
lookup was retried after a backoff on the client's scheduled executor, and
the close then shut the executor down with shutdownNow(), which discarded
the retry.
getPartitionedTopicMetadata() now fails with AlreadyClosedException once
the client is closing or closed, and a lookup that fails while the client
is closing fails with AlreadyClosedException instead of being retried. The
check is isClosed() rather than state != Open, as in the producer and
consumer creation, because the transaction coordinator client looks up its
assign topic from the constructor, before the client is Open.
lhotari
left a comment
There was a problem hiding this comment.
Thanks for tracking down the closed-client hang and covering both the already-closed and the closing case with tests.
The entry check and the retry check both work as described. I ran both new tests on this head and they pass, and each one fails when only its own fix is removed (without the entry check the first test times out, without the retry check the second test times out). isClosed() is the right guard (it covers Closing, and the transaction coordinator constructor path stays allowed), and AlreadyClosedException matches the other entry points in PulsarClientImpl.
One case is still left pending, which I think is worth closing in this PR since it is the same symptom: a retry that is already sleeping in the backoff when close() starts. See the inline comment.
| return null; | ||
| } | ||
| // A closing client would discard the retry when it shuts down its executor, leaving the future pending | ||
| if (isClosed()) { |
There was a problem hiding this comment.
[BUG] a retry already queued in the backoff when close() starts is discarded and the future never completes
This check only helps when the retriable failure arrives after close() has already moved the client to Closing. If the failure arrives while the client is still Open, the retry is scheduled on scheduledExecutorProvider for nextDelay, and a close() during that delay ends in scheduledExecutorProvider.shutdownNow() (PulsarClientImpl.java:1696), which discards the queued task. Nothing else completes future: the lookup attempt has already failed, and remainingTime is only a counter read by the next callback, not a deadline.
I reproduced it on this head with a temporary test: the mocked LookupService returns failedFuture(ConnectException), the client is built with startingBackoffInterval(5, SECONDS), then getPartitionsForTopic(...) is called and client.close() right after. The future fails with TimeoutException instead of AlreadyClosedException. A real case is a caller blocked on getPartitionsForTopic(...).get() while the broker is unreachable, and then the client being closed from another thread.
The same discard also happens for the narrow window between this isClosed() check and schedule(...): if shutdown() has already stopped the executor, schedule throws RejectedExecutionException inside the exceptionally callback and the exception is dropped with the stage nobody observes.
One way to cover both: have the client keep the metadata futures that are still outstanding (register after creating the future, remove on completion) and fail whatever is left with AlreadyClosedException at the start of closeAsync()/before shutdown() stops the executors. Catching the RejectedExecutionException alone would not fix the sleeping-retry case. If you would rather keep this PR small, a follow-up is fine, but please then say so in the description, as it currently reads as if an in-flight lookup is fully handled.
Motivation
Problem. Once a
PulsarClientis closed,getPartitionsForTopic()returns a future that never completes: acaller blocked on
get()without a timeout hangs, and an asynchronous caller is never called back. Producer,consumer and reader creation fail at once with
AlreadyClosedExceptionafterclose(), becausecreateProducerAsync(),subscribeAsync()andcreateReaderAsync()check the client state.getPartitionedTopicMetadata(String, boolean, boolean), whichgetPartitionsForTopic()calls, doesn't. It is alsocalled by
ConsumerBuilderImpl.subscribeAsync()for the retry and dead-letter topics of a consumer withenableRetry(true), before the state check of the client'ssubscribeAsync()(so thatsubscribe()hangs on aclosed client too), by the partition auto-update of
MultiTopicsConsumerImplandPartitionedProducerImpl, and bythe broker when it checks the remote clusters before auto-creating a topic, through its replication clients.
On a closed client, the lookup itself never completes. The connection pool resolves the broker address with
Netty's
InetSocketAddressResolver, which adds a listener to a promise of the client's event loop. That loop hasterminated, so Netty can't submit the notification and drops it:
A lookup in flight when the client closes is left pending too, by the retry loop. Closing the connection fails the
request with
ConnectException("Disconnected from server at ..."), which is retriable, so the lookup is retriedafter a backoff on the client's scheduled executor. That happens while the client is closing, before it stops its
executors; it then stops them with
shutdownNow(), which discards the retry.Example.
The code path:
Modifications
getPartitionedTopicMetadata(String, boolean, boolean)fails withAlreadyClosedExceptiononce the client isclosing or closed. It checks
isClosed()rather thanstate != Openas the producer and consumer creation do:the transaction coordinator client looks up its assign topic from the client's constructor, before the client is
Open, so withstate != Openevery client built withenableTransaction(true)would fail to start.AlreadyClosedExceptioninstead of being retried, since the close would discard the retry.
A non-retriable error, or one that comes when the lookup has no time left, still fails the lookup with that error,
as before.
Since #27473,
shutdown()on its own marks the client closed as well, sogetPartitionsForTopic()aftershutdown()fails withAlreadyClosedExceptiontoo (checked locally with this change on top of #27473's).Verifying this change
This change added tests and can be verified as follows:
PulsarClientImplTest#testGetPartitionsForTopicFailsOnClosedClient:getPartitionsForTopic()on a closed clientmust fail with
AlreadyClosedExceptionwithin 10 seconds. On master it times out.PulsarClientImplTest#testGetPartitionsForTopicFailsWhenClientClosesDuringLookup: the lookup request fails with aretriable
ConnectExceptionwhile the client is closing, as when the client closes the connection it was senton.
getPartitionsForTopic()must fail withAlreadyClosedException. On master it times out: the retry isscheduled, then discarded when the close stops the executor.
PulsarClientImplTest(23) andTransactionClientCloseTest(7), whose clients have transactions enabled andso look up the coordinator's assign topic from the client's constructor, pass.
Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes