Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
61d0b60
Add hedgeSettings with configurable hedgeDelay
Jul 13, 2026
19afd68
Add HedgeTokenBucket class to allow for token operations
Jul 13, 2026
49426a9
Add publisher integration with HedgeSettings
Jul 13, 2026
22417d1
Restore original @Ignore annotations and code in PublisherImplTest
Jul 13, 2026
91706a2
style(pubsub): fix checkstyle violations in HedgeSettings
Jul 13, 2026
bd76ede
style(pubsub): fix formatting and checkstyle violations in HedgeSetti…
Jul 13, 2026
61aef08
Merge branch 'main' into publish-hedging-settings
tonyyyycui Jul 13, 2026
227f808
Resolve git comments related to access modifiers and constraints
Jul 20, 2026
e29eab8
Add CancellationSharer and HedgedRequest helper classes
Jul 20, 2026
ed05c60
Integrate CancellationSharer and HedgedRequest into Publisher.java. A…
Jul 20, 2026
23b8432
fix lint
Jul 20, 2026
3a23d60
Modifying comment line
Jul 20, 2026
da4307e
Modified default values, added configurable settings to refill ratio …
Jul 27, 2026
336c809
Removed HedgeTokenBucket.java class in favor of using an atomicintege…
Jul 27, 2026
59a7a09
Add documentation for scaling and remove unused getAttemptCount() in …
Jul 27, 2026
79411a8
Added method overloading to handle OTel span for hedged publish events
Jul 27, 2026
e545cd6
Verify that the per-RPC timeout is strictly greater than the hedging …
Jul 27, 2026
8543d23
Moved static methods in Publisher.java above the builder
Jul 27, 2026
3c48a1a
Configure empty token bucket and fix naming
Jul 27, 2026
0929a1b
Change from synchronization to actual lock object for processQueue me…
Jul 27, 2026
0295144
Add header injection for hedged publish requests
Jul 28, 2026
c8296f5
Cleanup code and fix bug with handling failure attempts in the first …
Jul 28, 2026
c8fd6e9
Disable retries for failing rpcs when publish hedging
Jul 28, 2026
0b88e33
Change how cancellationSharer propagates cancellations for different …
Jul 30, 2026
b6af015
Fixing race condition in unit test setup
Jul 30, 2026
be69b98
Merge branch 'main' into publish-hedging-settings
tonyyyycui Jul 30, 2026
1a4644f
Modify header to use an integer count instead of a bool
Jul 31, 2026
e87c26f
Merge branch 'publish-hedging-settings' of github.com:tonyyyycui/goog…
Jul 31, 2026
dd288c7
Fix lint
Jul 31, 2026
8885dc2
Modify scale factor and minimum refillRatio value. Add test annotatio…
Aug 4, 2026
2eb687f
Update scale factor, refill ratio constraints, and simplify token buc…
Aug 4, 2026
100db18
Rename HedgeSettings to HedgingSettings
Aug 4, 2026
ba05985
Inline the hedging attempt count headers map creation
Aug 4, 2026
97820e7
Added new subsystem to loggerUtil and adjusted logging usage
Aug 4, 2026
58a4f70
Add debugging for token bucket rate limiting
Aug 4, 2026
7361f15
Add publish end (hedged) event as well
Aug 4, 2026
52a7cb3
Add synchronized locking to resolve race condition in cancellationsharer
Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* 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.api.gax.rpc.ApiException;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.pubsub.v1.PublishResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
* 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;

// Guarded by lock
private final Map<Integer, ApiFuture<PublishResponse>> runningAttempts = new HashMap<>();
private boolean done = false;
private Throwable lastError;

private final Lock lock = new ReentrantLock();
private final AtomicBoolean isInQueue = new AtomicBoolean(false);

CancellationSharer(final Publisher.OutstandingBatch batch, final Publisher publisher) {
this.batch = batch;
this.publisher = publisher;
}

void addAttempt(final int attemptNumber, final ApiFuture<PublishResponse> future) {
lock.lock();
try {
if (done) {
future.cancel(true);
return;
}
runningAttempts.put(attemptNumber, future);
} finally {
lock.unlock();
}

ApiFutures.addCallback(
future,
new ApiFutureCallback<PublishResponse>() {
@Override
public void onSuccess(final PublishResponse result) {
handleAttemptSuccess(attemptNumber, result);
}

@Override
public void onFailure(final Throwable t) {
handleAttemptFailure(attemptNumber, t);
}
},
MoreExecutors.directExecutor());
}

private void handleAttemptSuccess(final int attemptNumber, final PublishResponse response) {
lock.lock();
try {
if (done) {
return;
}
done = true;
batch.successfulAttempt = attemptNumber;
set(response);
cancelAllExceptLocked(attemptNumber);
} finally {
lock.unlock();
}
publisher.refillTokenBucket();
}

private void handleAttemptFailure(final int attemptNumber, final Throwable t) {
boolean shouldRemoveFromQueue = false;
lock.lock();
try {
if (done) {
return; // <-- Exit early before modifying runningAttempts to avoid
// ConcurrentModificationException
}
runningAttempts.remove(attemptNumber);
lastError = t;

boolean isRetryable = true;
if (t instanceof ApiException) {
isRetryable =
publisher.getRetryableCodes().contains(((ApiException) t).getStatusCode().getCode());
}

if (runningAttempts.isEmpty() || !isRetryable) {
done = true;
setException(lastError);
cancelAllLocked();
if (isInQueue.get()) {
shouldRemoveFromQueue = true;
}
}
} finally {
lock.unlock();
}

if (shouldRemoveFromQueue) {
publisher.removeFromHedgingQueue(this);
}
}

void checkCompletionOnQueueExit() {
lock.lock();
try {
if (!done && runningAttempts.isEmpty() && !isInQueue.get()) {
done = true;
setException(
lastError != null
? lastError
: new RuntimeException("Hedging failed with no active attempts"));
}
} finally {
lock.unlock();
}
}

@Override
public boolean cancel(final boolean mayInterruptIfRunning) {
boolean cancelled = false;
boolean shouldRemoveFromQueue = false;
lock.lock();
try {
if (super.cancel(mayInterruptIfRunning)) {
cancelled = true;
done = true;
if (isInQueue.get()) {
shouldRemoveFromQueue = true;
}
cancelAllLocked();
}
} finally {
lock.unlock();
}

if (shouldRemoveFromQueue) {
publisher.removeFromHedgingQueue(this);
}
return cancelled;
}

private void cancelAllLocked() {
for (ApiFuture<PublishResponse> future : runningAttempts.values()) {
future.cancel(true);
}
runningAttempts.clear();
}

private void cancelAllExceptLocked(final int successfulAttempt) {
runningAttempts.forEach(
(attempt, future) -> {
if (attempt != successfulAttempt) {
future.cancel(true);
}
});
runningAttempts.clear();
}

AtomicBoolean isInQueue() {
return isInQueue;
}

Publisher.OutstandingBatch getBatch() {
return batch;
}
}
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* 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 HedgingSettings {
/** 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;

/** Minimum refill rate. */
private static final float MIN_REFILL_RATIO = 0.001f;

/** Maximum refill rate. */
private static final float MAX_REFILL_RATIO = 0.2f;

/** Hedging delay. */
private final Duration hedgeDelay;

/** Maximum tokens. */
private final int maxTokens;

/** Refill rate. */
private final float refillRatio;

private HedgingSettings(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 HedgingSettings}.
*
* @return a new builder.
*/
public static Builder newBuilder() {
return new Builder();
}

/** Builder for {@code HedgingSettings}. */
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 refillRatio the refill rate (tokens per successful request), must be 0.001 <=
* RefillRatio <= 0.2.
* @return this builder.
*/
public Builder setRefillRatio(final float refillRatio) {
if (refillRatio < MIN_REFILL_RATIO || refillRatio > MAX_REFILL_RATIO) {
throw new IllegalArgumentException(
"refillRatio must be greater than or equal to "
+ MIN_REFILL_RATIO
+ " and less than or equal to "
+ MAX_REFILL_RATIO);
}
this.refillRatio = refillRatio;
return this;
}

/**
* Builds an instance of {@code HedgingSettings}.
*
* @return the built {@code HedgingSettings} instance.
*/
public HedgingSettings build() {
return new HedgingSettings(this);
}
}
}
Loading
Loading