-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(pubsub): return cancelled messages to the publisher's waiter #14002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -643,6 +643,59 @@ public void testPublishThrowExceptionForUnsubmittedOrderingKeyMessage() throws E | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * When a batch for an ordering key fails, its failure callback also cancels the messages still | ||
| * accumulating in that key's un-flushed batch. Those messages incremented {@code messagesWaiter} | ||
| * when they were published and never become part of any {@code OutstandingBatch}, so they have to | ||
| * be returned to the waiter there — otherwise {@code pendingCount} can never reach zero again and | ||
| * {@code shutdown()}, which waits on it uninterruptibly and without a timeout, never returns. | ||
| */ | ||
| @Test(timeout = 60_000) | ||
| public void testShutdownAfterOrderingKeyFailureWithMoreOfThatKeyStillBatched() throws Exception { | ||
| Publisher publisher = | ||
| getTestPublisherBuilder() | ||
| .setBatchingSettings( | ||
| Publisher.Builder.DEFAULT_BATCHING_SETTINGS.toBuilder() | ||
| .setElementCountThreshold(2L) | ||
| .setDelayThresholdDuration(Duration.ofSeconds(100)) | ||
| .build()) | ||
| .setEnableMessageOrdering(true) | ||
| .build(); | ||
|
|
||
| // 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); | ||
|
Comment on lines
+664
to
+696
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 {
// 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);
}
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would rather not, for a reason specific to this test — happy to be overruled by a maintainer.
On the leak itself: |
||
| } | ||
|
|
||
| private ApiFuture<String> sendTestMessageWithOrderingKey( | ||
| Publisher publisher, String data, String orderingKey) { | ||
| return publisher.publish( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While this fix correctly accounts for cancelled messages, there is a potential race condition because
publish()increments the waiter after releasingmessagesBatchLock. If a message is added tomessagesBatchbut not yet counted when a failure occurs,cancelledMessagesCountwill include it, causingpendingCountto temporarily go negative (or hit zero prematurely). This can causewaitComplete()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 themessagesBatchLockblock in thepublish()method.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 aftermessagesBatch.add(...), still undermessagesBatchLock, so "in aMessagesBatch" and "counted" are one state. Two things I checked rather than assumed:messagesBatchLock→Waitermonitor here and nowhere the reverse —shutdown()callspublishAllOutstanding()andwaitComplete()sequentially, not nested — andincrementPendingCountis a non-blockingsynchronizedmethod, so there is no cycle.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
PublisherImplTestis green with both applied (33 run, 2 pre-existing@Ignores), andfmt-maven-plugin:checkreports 0 non-complying files.