Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -42,11 +42,13 @@
import com.google.api.gax.resumable.QueryStatusResponse;
import com.google.api.gax.resumable.ResumableUploadStatus;
import com.google.api.gax.retrying.ExponentialRetryAlgorithm;
import com.google.api.gax.retrying.RetrySettings;
import com.google.api.gax.retrying.TimedAttemptSettings;
import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category;
import com.google.common.util.concurrent.MoreExecutors;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Future;
Expand Down Expand Up @@ -75,6 +77,7 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
private final ExponentialRetryAlgorithm recoveryAlgorithm;
private final ScheduledExecutorService executor;
private final ResumableUploadProgressTracker progressTracker;
private final @Nullable ResumableUploadStallController stallController;
private final SettableApiFuture<ResponseT> uploadResultFuture = SettableApiFuture.create();
private volatile @Nullable Future<?> inFlightFuture;
private TimedAttemptSettings recoverySettings;
Expand All @@ -87,7 +90,8 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
InputStream payload,
int chunkSize,
ClientContext clientContext,
ResumableUploadProgressTracker progressTracker) {
ResumableUploadProgressTracker progressTracker,
@Nullable ResumableUploadStallController stallController) {
this.uploadChunkCallable =
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
this.queryStatusCallable =
Expand All @@ -101,11 +105,23 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
new ExponentialRetryAlgorithm(
ResumableUploadCallableImpl.RETRY_SETTINGS, clientContext.getClock());
this.executor = checkNotNull(clientContext.getExecutor(), "executor must not be null");
this.recoverySettings = recoveryAlgorithm.createFirstAttempt();
this.progressTracker = checkNotNull(progressTracker, "progressTracker must not be null");
this.stallController = stallController;
this.recoverySettings = createFirstRecoveryAttempt();
this.buffer = new RewindableStreamBuffer(payload, chunkSize);
Comment on lines 108 to 111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Remove the initialization of recoverySettings from the constructor because callContext is not yet initialized at construction time (it is null). Calling createFirstRecoveryAttempt() here will throw a NullPointerException when stallController is not null.

Suggested change
this.progressTracker = checkNotNull(progressTracker, "progressTracker must not be null");
this.stallController = stallController;
this.recoverySettings = createFirstRecoveryAttempt();
this.buffer = new RewindableStreamBuffer(payload, chunkSize);
this.progressTracker = checkNotNull(progressTracker, "progressTracker must not be null");
this.stallController = stallController;
this.buffer = new RewindableStreamBuffer(payload, chunkSize);

}

private TimedAttemptSettings createFirstRecoveryAttempt() {
if (stallController != null) {
return recoveryAlgorithm.createFirstAttempt(
callContext.withRetrySettings(
ResumableUploadCallableImpl.RETRY_SETTINGS.toBuilder()
.setTotalTimeoutDuration(Duration.ZERO)
.build()));
}
return recoveryAlgorithm.createFirstAttempt();
}
Comment on lines +114 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Update createFirstRecoveryAttempt to accept ApiCallContext callContext as a parameter. This allows us to pass the non-null callContext when initializing recoverySettings lazily during the recovery process.

Suggested change
private TimedAttemptSettings createFirstRecoveryAttempt() {
if (stallController != null) {
return recoveryAlgorithm.createFirstAttempt(
callContext.withRetrySettings(
ResumableUploadCallableImpl.RETRY_SETTINGS.toBuilder()
.setTotalTimeoutDuration(Duration.ZERO)
.build()));
}
return recoveryAlgorithm.createFirstAttempt();
}
private TimedAttemptSettings createFirstRecoveryAttempt(ApiCallContext callContext) {
if (stallController != null) {
return recoveryAlgorithm.createFirstAttempt(
callContext.withRetrySettings(
ResumableUploadCallableImpl.RETRY_SETTINGS.toBuilder()
.setTotalTimeoutDuration(Duration.ZERO)
.build()));
}
return recoveryAlgorithm.createFirstAttempt();
}


ApiFuture<ResponseT> getFuture() {
return uploadResultFuture;
}
Expand Down Expand Up @@ -149,10 +165,16 @@ private void transmitChunk() {
}

ChunkUploadRequest chunkRequest = buildCurrentChunkRequest();
ApiCallContext chunkCallContext = callContext;
if (stallController != null) {
chunkCallContext =
callContext.withRetrySettings(
stallController.onStartChunk(chunkRequest.getPayload().length));
}

// Dispatch the chunk upload call and register the in-flight future for cancellation.
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
uploadChunkCallable.futureCall(chunkRequest, callContext);
uploadChunkCallable.futureCall(chunkRequest, chunkCallContext);
if (!tryRegisterInFlightFuture(chunkFuture)) {
return;
}
Expand Down Expand Up @@ -189,6 +211,9 @@ public void onFailure(Throwable t) {
} else {
// Category.TRANSIENT errors reaching here have already exhausted their retry budget
// in the underlying RetryingCallable and become fatal per protocol specification.
if (stallController != null) {
t = stallController.translateException(t, ResumableUploadCommand.UPLOAD);
}
uploadResultFuture.setException(t);
}
}
Expand All @@ -203,8 +228,7 @@ private void recover(Throwable cause) {
progressTracker.onRecovering();
try {
if (madeProgressSinceRecovery) {
recoverySettings =
recoveryAlgorithm.createNextAttempt(recoveryAlgorithm.createFirstAttempt());
recoverySettings = recoveryAlgorithm.createNextAttempt(createFirstRecoveryAttempt());
} else {
recoverySettings = recoveryAlgorithm.createNextAttempt(recoverySettings);
}
Comment on lines 230 to 234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Initialize recoverySettings lazily inside the recover method where callContext is guaranteed to be non-null. This avoids the NullPointerException during construction.

      if (recoverySettings == null) {
        recoverySettings = createFirstRecoveryAttempt(callContext);
      }
      if (madeProgressSinceRecovery) {
        recoverySettings = recoveryAlgorithm.createNextAttempt(createFirstRecoveryAttempt(callContext));
      } else {
        recoverySettings = recoveryAlgorithm.createNextAttempt(recoverySettings);
      }

Expand All @@ -213,24 +237,34 @@ private void recover(Throwable cause) {
uploadResultFuture.setException(cause);
return;
}
Duration backoffDelay = recoverySettings.getRandomizedRetryDelayDuration();
RetrySettings queryRetrySettings = null;
if (stallController != null) {
queryRetrySettings = stallController.onStartQuery(backoffDelay);
}
RetrySettings finalQueryRetrySettings = queryRetrySettings;
tryRegisterInFlightFuture(
executor.schedule(
this::queryStatus,
recoverySettings.getRandomizedRetryDelayDuration().toNanos(),
() -> queryStatus(finalQueryRetrySettings),
backoffDelay.toNanos(),
TimeUnit.NANOSECONDS));
} catch (Throwable t) {
uploadResultFuture.setException(t);
}
}

private void queryStatus() {
private void queryStatus(@Nullable RetrySettings queryRetrySettings) {
try {
if (uploadResultFuture.isDone()) {
return;
}
ApiCallContext queryCallContext = callContext;
if (queryRetrySettings != null) {
queryCallContext = callContext.withRetrySettings(queryRetrySettings);
}
// Dispatch the query status call and register the in-flight future for cancellation.
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture =
queryStatusCallable.futureCall(QueryStatusRequest.create(uploadUrl), callContext);
queryStatusCallable.futureCall(QueryStatusRequest.create(uploadUrl), queryCallContext);
if (!tryRegisterInFlightFuture(queryFuture)) {
return;
}
Expand All @@ -254,6 +288,9 @@ public void onFailure(Throwable t) {
if (t instanceof CancellationException || uploadResultFuture.isDone()) {
return;
}
if (stallController != null) {
t = stallController.translateException(t, ResumableUploadCommand.QUERY);
}
uploadResultFuture.setException(t);
}
},
Expand Down Expand Up @@ -294,6 +331,10 @@ private void handleQueryResponse(QueryStatusResponse<ResponseT> queryResponse)
throw new IllegalStateException(
"Incomplete query status response did not include a committed offset");
}
long previousBaseOffset = buffer.getBufferBaseOffset();
if (stallController != null) {
stallController.onRecoveryOffsetReceived(committedOffset - previousBaseOffset);
}
progressTracker.onOffsetReceived(committedOffset, chunkSize);
buffer.realignTo(committedOffset);
transmitChunk();
Expand All @@ -309,6 +350,9 @@ private void handleChunkResponse(ChunkUploadResponse<ResponseT> response) throws
"Upload stream ended and final chunk was transmitted, but server returned"
+ " incomplete status"));
} else {
if (stallController != null) {
stallController.onChunkSuccess(buffer.getPayload().length);
}
madeProgressSinceRecovery = true;
buffer.fill();
progressTracker.onChunkUploaded(buffer.getBufferBaseOffset());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
private final ResumableUploadOptions options;
private final ClientContext clientContext;
private final ScheduledExecutorService executor;
private final boolean isResume;
private final ResumableUploadProgressTracker progressTracker;
private final @Nullable ResumableUploadStallController stallController;
private final SettableApiFuture<ResponseT> resultFuture = SettableApiFuture.create();

private volatile @Nullable InputStream payload;
Expand Down Expand Up @@ -157,9 +159,13 @@ static <ResponseT> ResumableUploadFutureImpl<ResponseT> createForResume(
sessionUrl, uploadChunkCallable, queryStatusCallable, options, clientContext);
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture;
try {
ApiCallContext queryCallContext = clientContext.getDefaultCallContext();
if (future.stallController != null) {
queryCallContext =
queryCallContext.withRetrySettings(future.stallController.onStartQuery(Duration.ZERO));
}
queryFuture =
queryStatusCallable.futureCall(
QueryStatusRequest.create(sessionUrl), clientContext.getDefaultCallContext());
queryStatusCallable.futureCall(QueryStatusRequest.create(sessionUrl), queryCallContext);
} catch (Throwable t) {
queryFuture = ApiFutures.immediateFailedFuture(t);
}
Expand Down Expand Up @@ -188,6 +194,7 @@ private ResumableUploadFutureImpl(
ResumableUploadOptions options,
ClientContext clientContext) {
this.uploadSessionUrl = sessionUrl;
this.isResume = sessionUrl != null;
this.uploadChunkCallable =
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
this.queryStatusCallable =
Expand All @@ -196,8 +203,10 @@ private ResumableUploadFutureImpl(
checkArgument(options.getChunkSize() > 0, "chunkSize must be > 0");
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
this.executor = checkNotNull(clientContext.getExecutor(), "executor must not be null");
if (sessionUrl != null) {
this.progressTracker = ResumableUploadProgressTracker.createForResume(sessionUrl);
this.stallController = createStallController(options, clientContext);
if (this.isResume) {
this.progressTracker =
ResumableUploadProgressTracker.createForResume(checkNotNull(sessionUrl));
} else {
this.progressTracker = ResumableUploadProgressTracker.create();
}
Expand All @@ -207,6 +216,24 @@ private ResumableUploadFutureImpl(
}
}

private static @Nullable ResumableUploadStallController createStallController(
ResumableUploadOptions options, ClientContext clientContext) {
if (!options.isStallControlEnabled()) {
return null;
}
Duration globalDeadline = null;
if (options.getGlobalTimeout() != null) {
globalDeadline =
Duration.ofMillis(clientContext.getClock().millisTime()).plus(options.getGlobalTimeout());
}
return new ResumableUploadStallController(
clientContext.getClock(),
checkNotNull(options.getTransferStallMinimumRate()),
checkNotNull(options.getTransferStallTimeout()),
globalDeadline,
ResumableUploadCallableImpl.RETRY_SETTINGS);
}

private <T> void initiate(
ApiFuture<T> initiationFuture,
InputStreamSupplier payloadSupplier,
Expand Down Expand Up @@ -235,14 +262,25 @@ public void onFailure(Throwable t) {
if (t instanceof CancellationException || resultFuture.isDone()) {
return;
}
if (isResume && stallController != null) {
t = stallController.translateException(t, ResumableUploadCommand.QUERY);
}
fail(t);
}
},
MoreExecutors.directExecutor());
}

private void setUpGlobalTimeout() {
Duration timeout = firstNonNull(options.getGlobalTimeout(), DEFAULT_GLOBAL_TIMEOUT);
Duration timeout;
if (options.isStallControlEnabled()) {
timeout = options.getGlobalTimeout();
} else {
timeout = firstNonNull(options.getGlobalTimeout(), DEFAULT_GLOBAL_TIMEOUT);
}
if (timeout == null) {
return;
}
ScheduledFuture<?> timeoutFuture =
executor.schedule(this::onTimeout, timeout.toMillis(), TimeUnit.MILLISECONDS);
resultFuture.addListener(() -> timeoutFuture.cancel(false), MoreExecutors.directExecutor());
Expand All @@ -259,7 +297,8 @@ private void setUpGlobalTimeout() {
checkNotNull(payload),
chunkSize,
clientContext,
progressTracker);
progressTracker,
stallController);
ApiFuture<ResponseT> uploadFuture = coordinator.getFuture();
synchronized (lock) {
if (inFlightFuture == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ public abstract class ResumableUploadOptions {
*/
public abstract @Nullable ResumableUploadProgressListener getProgressListener();

/**
* Returns the minimum acceptable transfer rate in MiB/s (1 MiB = 1,048,576 bytes) for stall
* detection, or {@code null} if unset. Stall control is only active when both {@link
* #getTransferStallMinimumRate()} and {@link #getTransferStallTimeout()} are set.
*/
public abstract @Nullable Double getTransferStallMinimumRate();

/**
* Returns the maximum duration the upload may remain below {@link #getTransferStallMinimumRate()}
* before being aborted as stalled, or {@code null} if unset. Stall control is only active when
* both {@link #getTransferStallMinimumRate()} and {@link #getTransferStallTimeout()} are set.
*/
public abstract @Nullable Duration getTransferStallTimeout();

boolean isStallControlEnabled() {
return getTransferStallMinimumRate() != null && getTransferStallTimeout() != null;
}

/**
* Merges another {@code ResumableUploadOptions} instance with this one. Fields set in {@code
* other} override fields in this instance.
Expand All @@ -76,8 +94,17 @@ public ResumableUploadOptions merge(@Nullable ResumableUploadOptions other) {
if (other.getChunkSize() > 0) {
builder.setChunkSize(other.getChunkSize());
}
if (other.getTransferStallMinimumRate() != null) {
builder.setTransferStallMinimumRate(other.getTransferStallMinimumRate());
}
if (other.getTransferStallTimeout() != null) {
builder.setTransferStallTimeout(other.getTransferStallTimeout());
}
if (other.getGlobalTimeout() != null) {
builder.setGlobalTimeout(other.getGlobalTimeout());
} else if (builder.getTransferStallMinimumRate() != null
&& builder.getTransferStallTimeout() != null) {
builder.setGlobalTimeout(null);
}
Comment on lines +105 to 108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic causes a side-effect where merging an options instance that has both stall control and a custom globalTimeout with an empty/default ResumableUploadOptions instance will silently clear the custom globalTimeout. Merging with an empty/default instance should generally be a no-op. Consider if this behavior is truly desired, or if the disabling of the default global timeout should be handled solely in ResumableUploadFutureImpl.setUpGlobalTimeout().

References
  1. For resumable uploads, prevent infinite loops of recovery attempts by enforcing a global timeout rather than tracking and limiting consecutive recovery attempts.

ResumableUploadProgressListener otherProgressListener = other.getProgressListener();
if (otherProgressListener != null) {
Expand Down Expand Up @@ -128,6 +155,33 @@ public abstract static class Builder {
public abstract Builder setProgressListener(
@Nullable ResumableUploadProgressListener progressListener);

/**
* Sets the minimum acceptable transfer rate in MiB/s (1 MiB = 1,048,576 bytes) for stall
* detection. Must be finite and positive. Stall control is only active when both {@code
* transferStallMinimumRate} and {@code transferStallTimeout} are set; enabling stall control
* without an explicit {@link #setGlobalTimeout(Duration)} disables the default global timeout.
*/
public abstract Builder setTransferStallMinimumRate(@Nullable Double transferStallMinimumRate);

/**
* Returns the minimum acceptable transfer rate in MiB/s, or {@code null} if unset. See {@link
* #setTransferStallMinimumRate(Double)}.
*/
public abstract @Nullable Double getTransferStallMinimumRate();

/**
* Sets the maximum duration the upload may remain below {@link #getTransferStallMinimumRate()}
* before being aborted as stalled. Must be positive. Stall control is only active when both
* {@code transferStallMinimumRate} and {@code transferStallTimeout} are set.
*/
public abstract Builder setTransferStallTimeout(@Nullable Duration transferStallTimeout);

/**
* Returns the stall timeout duration, or {@code null} if unset. See {@link
* #setTransferStallTimeout(Duration)}.
*/
public abstract @Nullable Duration getTransferStallTimeout();

abstract ResumableUploadOptions autoBuild();

public ResumableUploadOptions build() {
Expand All @@ -137,6 +191,16 @@ public ResumableUploadOptions build() {
!getGlobalTimeout().isNegative() && !getGlobalTimeout().isZero(),
"globalTimeout must be positive");
}
if (getTransferStallMinimumRate() != null) {
Preconditions.checkArgument(
Double.isFinite(getTransferStallMinimumRate()) && getTransferStallMinimumRate() > 0.0,
"transferStallMinimumRate must be finite and positive");
}
if (getTransferStallTimeout() != null) {
Preconditions.checkArgument(
!getTransferStallTimeout().isNegative() && !getTransferStallTimeout().isZero(),
"transferStallTimeout must be positive");
}
return autoBuild();
}
}
Expand Down
Loading
Loading