-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(pubsub): implement publish hedging to reduce tail latency #13735
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
Open
tonyyyycui
wants to merge
24
commits into
googleapis:main
Choose a base branch
from
tonyyyycui:publish-hedging-settings
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
61d0b60
Add hedgeSettings with configurable hedgeDelay
19afd68
Add HedgeTokenBucket class to allow for token operations
49426a9
Add publisher integration with HedgeSettings
22417d1
Restore original @Ignore annotations and code in PublisherImplTest
91706a2
style(pubsub): fix checkstyle violations in HedgeSettings
bd76ede
style(pubsub): fix formatting and checkstyle violations in HedgeSetti…
61aef08
Merge branch 'main' into publish-hedging-settings
tonyyyycui 227f808
Resolve git comments related to access modifiers and constraints
e29eab8
Add CancellationSharer and HedgedRequest helper classes
ed05c60
Integrate CancellationSharer and HedgedRequest into Publisher.java. A…
23b8432
fix lint
3a23d60
Modifying comment line
da4307e
Modified default values, added configurable settings to refill ratio …
336c809
Removed HedgeTokenBucket.java class in favor of using an atomicintege…
59a7a09
Add documentation for scaling and remove unused getAttemptCount() in …
79411a8
Added method overloading to handle OTel span for hedged publish events
e545cd6
Verify that the per-RPC timeout is strictly greater than the hedging …
8543d23
Moved static methods in Publisher.java above the builder
3c48a1a
Configure empty token bucket and fix naming
0929a1b
Change from synchronization to actual lock object for processQueue me…
0295144
Add header injection for hedged publish requests
c8296f5
Cleanup code and fix bug with handling failure attempts in the first …
c8fd6e9
Disable retries for failing rpcs when publish hedging
d9a8655
Merge branch 'main' into publish-hedging-settings
tonyyyycui File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
140 changes: 140 additions & 0 deletions
140
...bsub/google-cloud-pubsub/src/main/java/com/google/cloud/pubsub/v1/CancellationSharer.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.google.cloud.pubsub.v1; | ||
|
|
||
| import com.google.api.core.AbstractApiFuture; | ||
| import com.google.api.core.ApiFuture; | ||
| import com.google.api.core.ApiFutureCallback; | ||
| import com.google.api.core.ApiFutures; | ||
| import com.google.common.util.concurrent.MoreExecutors; | ||
| import com.google.pubsub.v1.PublishResponse; | ||
| import java.util.Map; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
|
|
||
| /** | ||
| * Coordinates multiple publish attempts for a single batch of messages. | ||
| * | ||
| * <p>Implements {@link ApiFuture} to act as the single future returned to the publisher's client. | ||
| * It manages the lifecycle of the original attempt and any subsequent hedged attempts. | ||
| */ | ||
| class CancellationSharer extends AbstractApiFuture<PublishResponse> { | ||
| private final Publisher.OutstandingBatch batch; | ||
| private final Publisher publisher; | ||
| private final Map<Integer, ApiFuture<PublishResponse>> runningAttempts = | ||
| new ConcurrentHashMap<>(); | ||
| private final AtomicBoolean done = new AtomicBoolean(false); | ||
| final AtomicBoolean isInQueue = new AtomicBoolean(false); | ||
| private final AtomicReference<Throwable> lastError = new AtomicReference<>(); | ||
|
|
||
| CancellationSharer(Publisher.OutstandingBatch batch, Publisher publisher) { | ||
| this.batch = batch; | ||
| this.publisher = publisher; | ||
| } | ||
|
|
||
| /** | ||
| * Adds an attempt to be tracked by this coordinator. | ||
| * | ||
| * @param attemptNumber the 1-based index of the attempt (1 is original, 2+ are hedged) | ||
| * @param future the future representing the gRPC call for this attempt | ||
| */ | ||
| void addAttempt(final int attemptNumber, ApiFuture<PublishResponse> future) { | ||
| runningAttempts.put(attemptNumber, future); | ||
|
|
||
| if (done.get()) { | ||
| future.cancel(true); | ||
| runningAttempts.remove(attemptNumber); | ||
| return; | ||
| } | ||
|
|
||
| ApiFutures.addCallback( | ||
| future, | ||
| new ApiFutureCallback<PublishResponse>() { | ||
| @Override | ||
| public void onSuccess(PublishResponse result) { | ||
| handleAttemptSuccess(attemptNumber, result); | ||
| } | ||
|
|
||
| @Override | ||
| public void onFailure(Throwable t) { | ||
| handleAttemptFailure(attemptNumber, t); | ||
| } | ||
| }, | ||
| MoreExecutors.directExecutor()); | ||
| } | ||
|
|
||
| private void handleAttemptSuccess(int attemptNumber, PublishResponse response) { | ||
| if (done.compareAndSet(false, true)) { | ||
| set(response); // Resolve parent future | ||
| cancelAllExcept(attemptNumber); | ||
| publisher.refillTokenBucket(); | ||
| } | ||
| } | ||
|
|
||
| private void handleAttemptFailure(int attemptNumber, Throwable t) { | ||
| runningAttempts.remove(attemptNumber); | ||
|
|
||
| if (done.get()) { | ||
| return; | ||
| } | ||
| lastError.set(t); | ||
| if (runningAttempts.isEmpty() && done.compareAndSet(false, true)) { | ||
| setException(lastError.get()); | ||
| if (isInQueue.get()) { | ||
| publisher.removeFromHedgingQueue(this); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| void checkCompletionOnQueueExit() { | ||
| if (!done.get() && runningAttempts.isEmpty() && !isInQueue.get()) { | ||
| if (done.compareAndSet(false, true)) { | ||
| Throwable error = lastError.get(); | ||
| setException( | ||
| error != null ? error : new RuntimeException("Hedging failed with no active attempts")); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void cancelAllExcept(int successfulAttempt) { | ||
| for (Map.Entry<Integer, ApiFuture<PublishResponse>> entry : runningAttempts.entrySet()) { | ||
| if (entry.getKey() != successfulAttempt) { | ||
| entry.getValue().cancel(true); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public boolean cancel(boolean mayInterruptIfRunning) { | ||
| if (super.cancel(mayInterruptIfRunning)) { | ||
| done.set(true); | ||
| if (isInQueue.get()) { | ||
| publisher.removeFromHedgingQueue(this); | ||
| } | ||
| for (ApiFuture<PublishResponse> future : runningAttempts.values()) { | ||
| future.cancel(mayInterruptIfRunning); | ||
| } | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| Publisher.OutstandingBatch getBatch() { | ||
| return batch; | ||
| } | ||
| } |
143 changes: 143 additions & 0 deletions
143
java-pubsub/google-cloud-pubsub/src/main/java/com/google/cloud/pubsub/v1/HedgeSettings.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.google.cloud.pubsub.v1; | ||
|
|
||
| import com.google.common.base.Preconditions; | ||
| import java.time.Duration; | ||
|
|
||
| /** Settings for configuring publish hedging. */ | ||
| public final class HedgeSettings { | ||
| /** Default hedging delay. */ | ||
| private static final Duration DEFAULT_DELAY = Duration.ofMillis(1000); | ||
|
|
||
| /** Default maximum number of tokens in the bucket. */ | ||
| private static final int DEFAULT_MAX_TOKENS = 50; | ||
|
|
||
| /** Default refill rate (tokens per successful request). */ | ||
| private static final float DEFAULT_REFILL_RATIO = 0.1f; | ||
|
|
||
| /** Hedging delay. */ | ||
| private final Duration hedgeDelay; | ||
|
|
||
| /** Maximum tokens. */ | ||
| private final int maxTokens; | ||
|
|
||
| /** Refill rate. */ | ||
| private final float refillRatio; | ||
|
|
||
| private HedgeSettings(final Builder builder) { | ||
| this.hedgeDelay = builder.hedgeDelay; | ||
| this.maxTokens = builder.maxTokens; | ||
| this.refillRatio = builder.refillRatio; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the configured hedging delay. | ||
| * | ||
| * @return the hedging delay. | ||
| */ | ||
| Duration getHedgeDelay() { | ||
| return hedgeDelay; | ||
| } | ||
|
|
||
| int getMaxTokens() { | ||
| return maxTokens; | ||
| } | ||
|
|
||
| float getRefillRatio() { | ||
| return refillRatio; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a new builder for {@code HedgeSettings}. | ||
| * | ||
| * @return a new builder. | ||
| */ | ||
| public static Builder newBuilder() { | ||
| return new Builder(); | ||
| } | ||
|
|
||
| /** Builder for {@code HedgeSettings}. */ | ||
| public static final class Builder { | ||
| /** Hedging delay. */ | ||
| private Duration hedgeDelay = DEFAULT_DELAY; | ||
|
|
||
| /** Maximum tokens. */ | ||
| private int maxTokens = DEFAULT_MAX_TOKENS; | ||
|
|
||
| /** Refill rate. */ | ||
| private float refillRatio = DEFAULT_REFILL_RATIO; | ||
|
|
||
| private Builder() {} | ||
|
|
||
| /** | ||
| * Allows hedging delay to be configurable. | ||
| * | ||
| * @param delay the hedging delay, must be 0.1s <= HedgeDelay <= 10s. | ||
| * @return this builder. | ||
| */ | ||
| public Builder setHedgeDelay(final Duration delay) { | ||
| Preconditions.checkNotNull(delay); | ||
| if (delay.toMillis() < 100 || delay.toMillis() > 10000) { | ||
| throw new IllegalArgumentException( | ||
| "hedgeDelay must be greater than or equal to 100ms and less than or equal to 10s"); | ||
| } | ||
| this.hedgeDelay = delay; | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Allows the maximum number of tokens in the bucket to be configurable. | ||
| * | ||
| * @param maxTokens the maximum number of tokens, must be 0 < MaxTokens <= 250. | ||
| * @return this builder. | ||
| */ | ||
| public Builder setMaxTokens(final int maxTokens) { | ||
| if (maxTokens <= 0 || maxTokens > 250) { | ||
| throw new IllegalArgumentException( | ||
| "maxTokens must be greater than 0 and less than or equal to 250"); | ||
| } | ||
| this.maxTokens = maxTokens; | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Allows the token bucket refill rate to be configurable. | ||
| * | ||
| * @param refill the refill rate (tokens per successful request), must be 0 < RefillRatio <= | ||
| * 0.2. | ||
| * @return this builder. | ||
| */ | ||
| public Builder setRefillRatio(final float refillRatio) { | ||
| if (refillRatio <= 0.0f || refillRatio > 0.2f) { | ||
| throw new IllegalArgumentException( | ||
| "refillRatio must be greater than 0.0 and less than or equal to 0.2"); | ||
| } | ||
| this.refillRatio = refillRatio; | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Builds an instance of {@code HedgeSettings}. | ||
| * | ||
| * @return the built {@code HedgeSettings} instance. | ||
| */ | ||
| public HedgeSettings build() { | ||
| return new HedgeSettings(this); | ||
| } | ||
| } | ||
| } | ||
42 changes: 42 additions & 0 deletions
42
java-pubsub/google-cloud-pubsub/src/main/java/com/google/cloud/pubsub/v1/HedgedRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.google.cloud.pubsub.v1; | ||
|
|
||
| /** Represents a pending hedging check in the publisher's queue. */ | ||
| class HedgedRequest { | ||
| private final CancellationSharer coordinator; | ||
| private final int attemptNumber; | ||
| private final long sendAfterMs; | ||
|
|
||
| HedgedRequest(CancellationSharer coordinator, int attemptNumber, long sendAfterMs) { | ||
| this.coordinator = coordinator; | ||
| this.attemptNumber = attemptNumber; | ||
| this.sendAfterMs = sendAfterMs; | ||
| } | ||
|
|
||
| CancellationSharer getCoordinator() { | ||
| return coordinator; | ||
| } | ||
|
|
||
| int getAttemptNumber() { | ||
| return attemptNumber; | ||
| } | ||
|
|
||
| long getSendAfterMs() { | ||
| return sendAfterMs; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.