Skip to content

feat(gax): enable stall control for resumable uploads - #14989

Draft
whowes wants to merge 1 commit into
whowes/resumable-stall-controllerfrom
whowes/resumable-stall-control
Draft

whowes wants to merge 1 commit into
whowes/resumable-stall-controllerfrom
whowes/resumable-stall-control

Conversation

@whowes

@whowes whowes commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

WIP - not ready for review

@whowes
whowes added this pull request to stack #14988 November 12, 2026 17:17

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces stall control capabilities for resumable uploads by adding configuration options for minimum transfer rate and stall timeout, and integrating a stall controller to monitor progress and handle recovery. The feedback highlights a critical NullPointerException in ResumableUploadChunkCoordinator due to accessing the uninitialized callContext during construction; it is recommended to initialize recoverySettings lazily within the recover method. Additionally, the merging logic in ResumableUploadOptions should be revised to prevent a custom global timeout from being silently cleared when merging with an empty options instance.

Comment on lines 108 to 111
this.progressTracker = checkNotNull(progressTracker, "progressTracker must not be null");
this.stallController = stallController;
this.recoverySettings = createFirstRecoveryAttempt();
this.buffer = new RewindableStreamBuffer(payload, chunkSize);

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);

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

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();
}

Comment on lines 230 to 234
if (madeProgressSinceRecovery) {
recoverySettings =
recoveryAlgorithm.createNextAttempt(recoveryAlgorithm.createFirstAttempt());
recoverySettings = recoveryAlgorithm.createNextAttempt(createFirstRecoveryAttempt());
} else {
recoverySettings = recoveryAlgorithm.createNextAttempt(recoverySettings);
}

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);
      }

Comment on lines +105 to 108
} else if (builder.getTransferStallMinimumRate() != null
&& builder.getTransferStallTimeout() != null) {
builder.setGlobalTimeout(null);
}

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.

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed for 'gapic-generator-java-root'

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant