fix(pubsub): return cancelled messages to the publisher's waiter - #14002
fix(pubsub): return cancelled messages to the publisher's waiter#14002laughingman7743 wants to merge 2 commits into
Conversation
When a batch for an ordering key fails, the failure callback also cancels the messages still accumulating in that key's un-flushed MessagesBatch and drops the batch, but decrements messagesWaiter only by the in-flight batch's size. Those cancelled messages each incremented the waiter when they were published and never become part of any OutstandingBatch, so nothing ever decrements for them and pendingCount can no longer reach zero. Publisher.shutdown() waits on that counter uninterruptibly and without a timeout, so it never returns. awaitTermination(timeout, unit) is documented to be called after shutdown(), so its bound is never reached either. Return the cancelled count to the waiter alongside the batch's own. Fixes googleapis#14001
There was a problem hiding this comment.
Code Review
This pull request fixes a bug where the publisher's shutdown process hangs indefinitely after an ordering key failure when there are still un-flushed messages accumulating for that key. It resolves this by tracking and accounting for these cancelled messages in the pending messages waiter. A corresponding unit test was also added. The review feedback highlights a potential race condition where the waiter increment and batching are not atomic, which could cause the pending count to temporarily go negative, and suggests wrapping the new test's execution in a try-finally block to ensure the publisher is always cleaned up.
| // Messages cancelled below are dropped without ever becoming part of an | ||
| // OutstandingBatch, so they are owed back to messagesWaiter here; nothing else will | ||
| // ever decrement for them. | ||
| int cancelledMessagesCount = 0; |
There was a problem hiding this comment.
While this fix correctly accounts for cancelled messages, there is a potential race condition because publish() increments the waiter after releasing messagesBatchLock. If a message is added to messagesBatch but not yet counted when a failure occurs, cancelledMessagesCount will include it, causing pendingCount to temporarily go negative (or hit zero prematurely). This can cause waitComplete() to return early before the publishing thread actually finishes.
To make "batched" and "counted" atomic and completely eliminate this race, consider moving the messagesWaiter.incrementPendingCount(1) call inside the messagesBatchLock block in the publish() method.
There was a problem hiding this comment.
Agreed, and done in 4f3fcd8 — kept as a separate commit so it can be dropped independently of the accounting fix.
incrementPendingCount(1) now happens immediately after messagesBatch.add(...), still under messagesBatchLock, so "in a MessagesBatch" and "counted" are one state. Two things I checked rather than assumed:
- Lock ordering. It is
messagesBatchLock→Waitermonitor here and nowhere the reverse —shutdown()callspublishAllOutstanding()andwaitComplete()sequentially, not nested — andincrementPendingCountis a non-blockingsynchronizedmethod, so there is no cycle. - The paused-key path still does not increment, since it returns at the
keyHasErrorcheck before reaching the new position.
Also measured that the two commits are genuinely independent: with the increment moved but the accounting fix reverted, the new test still times out in shutdownTestPublisher → shutdown → Object.wait. So this commit is hardening, not a second fix for the same bug.
Full PublisherImplTest is green with both applied (33 run, 2 pre-existing @Ignores), and fmt-maven-plugin:check reports 0 non-complying files.
|
|
||
| // Queued before publishing, so the fake never blocks in publishResponses.take() (see #13394). | ||
| testPublisherServiceImpl.addPublishError(new StatusException(Status.INVALID_ARGUMENT)); | ||
|
|
||
| // m1 and m2 meet the threshold and are popped into an outstanding batch, but the request only | ||
| // leaves once the fake executor runs — so m3 is published into the un-flushed batch for the | ||
| // same key first, and is still there when the failure lands. | ||
| ApiFuture<String> publishFuture1 = sendTestMessageWithOrderingKey(publisher, "m1", "orderA"); | ||
| ApiFuture<String> publishFuture2 = sendTestMessageWithOrderingKey(publisher, "m2", "orderA"); | ||
| ApiFuture<String> publishFuture3 = sendTestMessageWithOrderingKey(publisher, "m3", "orderA"); | ||
| assertFalse(publishFuture3.isDone()); | ||
|
|
||
| fakeExecutor.advanceTime(Duration.ZERO); | ||
|
|
||
| try { | ||
| publishFuture1.get(); | ||
| fail("This should fail."); | ||
| } catch (ExecutionException e) { | ||
| } | ||
| try { | ||
| publishFuture2.get(); | ||
| fail("This should fail."); | ||
| } catch (ExecutionException e) { | ||
| } | ||
| try { | ||
| publishFuture3.get(); | ||
| fail("This should fail."); | ||
| } catch (ExecutionException e) { | ||
| assertEquals(SequentialExecutorService.CallbackExecutor.CANCELLATION_EXCEPTION, e.getCause()); | ||
| } | ||
|
|
||
| // Hangs here without the accounting fix: m3's increment was never returned. | ||
| shutdownTestPublisher(publisher); |
There was a problem hiding this comment.
To prevent resource leaks (such as active background threads or executors) if any assertions fail or unexpected exceptions are thrown during the test, wrap the test execution in a try-finally block to ensure shutdownTestPublisher(publisher) is always executed.
try {
// Queued before publishing, so the fake never blocks in publishResponses.take() (see #13394).
testPublisherServiceImpl.addPublishError(new StatusException(Status.INVALID_ARGUMENT));
// m1 and m2 meet the threshold and are popped into an outstanding batch, but the request only
// leaves once the fake executor runs — so m3 is published into the un-flushed batch for the
// same key first, and is still there when the failure lands.
ApiFuture<String> publishFuture1 = sendTestMessageWithOrderingKey(publisher, "m1", "orderA");
ApiFuture<String> publishFuture2 = sendTestMessageWithOrderingKey(publisher, "m2", "orderA");
ApiFuture<String> publishFuture3 = sendTestMessageWithOrderingKey(publisher, "m3", "orderA");
assertFalse(publishFuture3.isDone());
fakeExecutor.advanceTime(Duration.ZERO);
try {
publishFuture1.get();
fail("This should fail.");
} catch (ExecutionException e) {
}
try {
publishFuture2.get();
fail("This should fail.");
} catch (ExecutionException e) {
}
try {
publishFuture3.get();
fail("This should fail.");
} catch (ExecutionException e) {
assertEquals(SequentialExecutorService.CallbackExecutor.CANCELLATION_EXCEPTION, e.getCause());
}
} finally {
// Hangs here without the accounting fix: m3's increment was never returned.
shutdownTestPublisher(publisher);
}There was a problem hiding this comment.
I would rather not, for a reason specific to this test — happy to be overruled by a maintainer.
shutdownTestPublisher(publisher) is the assertion here: without the production change it blocks forever, which is what makes the test fail. Moving it into a finally means that when one of the earlier assertions fails, the test hangs in the finally and JUnit reports TestTimedOut instead of the assertion — the diagnostic that actually says what went wrong is replaced by the one that does not.
On the leak itself: tearDown() already shuts the in-process server down and closes the channel unconditionally, and the publisher's executor here is the FakeScheduledExecutorService, not a real pool — so a failed assertion leaves no live thread behind. The other 15 tests in this class call shutdownTestPublisher(publisher) as the last statement without a try/finally, so this also keeps the file consistent.
The failure callback decrements messagesWaiter for the messages it cancels out of a MessagesBatch, but publish() incremented after releasing messagesBatchLock. A message visible in the batch and not yet counted would therefore be decremented for without ever having been counted, taking pendingCount below zero and letting waitComplete() return early. Incrementing while the lock is still held makes "in a MessagesBatch" and "counted" one state. Lock ordering is messagesBatchLock -> Waiter monitor here and nowhere the reverse, and incrementPendingCount never blocks. The paused-key path still returns before the increment, as before.
Fixes #14001.
The defect
Publisher.publishcounts every accepted message intomessagesWaiter. Both batch callbacksdecrement by the size of the batch that was in flight. But before that
finally, the failurecallback also cancels the messages still accumulating in the un-flushed
MessagesBatchfor thefailed ordering key, and drops the batch:
Each of those incremented the waiter when it was published, and none is part of any
OutstandingBatch, so nothing ever decrements for them.pendingCountstays at least equal to thenumber cancelled, for the life of the publisher.
Waiter.waitComplete()ignores interruption and wakes only on an exact zero, andPublisher.shutdown()calls it directly — so the calling thread parks permanently.awaitTermination(timeout, unit)is the API that takes a bound, but it is documented to be calledafter
shutdown(), so it is never reached.The change
Count what the failure callback cancels and return it to the waiter alongside the batch's own size.
MessagesBatch.getMessagesCount()already exists, so the change is local to that callback.Verification
PublisherImplTest.testShutdownAfterOrderingKeyFailureWithMoreOfThatKeyStillBatchedis new anddiscriminating — measured both ways, not assumed:
shutdownTestPublisher → Publisher.shutdown → Object.wait, which ismessagesWaiter.waitComplete();The whole of
PublisherImplTestpasses (33 run, 2 pre-existing@Ignores), andfmt-maven-plugin:checkreports no non-complying files.The test queues the error before publishing rather than after, so
FakePublisherServiceImpl.publishnever blocks in
publishResponses.take()— the hazard behind #13394. It needs no response delay:with the fake executor, the request only leaves once
advanceTimeruns, so the third message isdeterministically in the un-flushed batch when the failure lands.
One thing worth a maintainer's opinion
publish()increments the waiter after releasingmessagesBatchLock, so a message can be in aMessagesBatchbefore it is counted. With this patch that can drivependingCounttransientlynegative — self-correcting, since the pending
+1lands and the counter reaches zero, but it meanswaitComplete()could return marginally early for a message that was just cancelled anyway.Moving the increment inside the lock would make "batched" and "counted" one atomic state. The lock
order is already
messagesBatchLock→Waitermonitor everywhere, so there is no inversion. I havedeliberately not done that here — it widens the change beyond the accounting bug, and the call is
yours.