Skip to content

Commit 15c09c7

Browse files
committed
feat(gax): add progress listener models and ResumableUploadProgressTracker
1 parent 1c271f8 commit 15c09c7

4 files changed

Lines changed: 503 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.resumable;
31+
32+
import com.google.api.core.BetaApi;
33+
import com.google.auto.value.AutoValue;
34+
import org.jspecify.annotations.NullMarked;
35+
import org.jspecify.annotations.Nullable;
36+
37+
/** Progress snapshot of an ongoing or completed resumable upload session. */
38+
@BetaApi
39+
@NullMarked
40+
@AutoValue
41+
public abstract class ResumableUploadProgress {
42+
43+
ResumableUploadProgress() {}
44+
45+
/** The state of the resumable upload session. */
46+
public enum State {
47+
/** Session initiation is in progress (acquiring upload session URL). */
48+
STARTING,
49+
50+
/** The session initiation completed successfully. */
51+
STARTED,
52+
53+
/** Transmitting chunk payloads to the server. */
54+
UPLOADING,
55+
56+
/** A recoverable error occurred; querying server status and resynchronizing offset. */
57+
RECOVERING,
58+
59+
/** The server query status succeeded and the committed offset was received. */
60+
OFFSET_RECEIVED,
61+
62+
/** The upload was successfully finalized by the server. */
63+
FINALIZED,
64+
65+
/** The upload failed unrecoverably or was cancelled. */
66+
FAILED
67+
}
68+
69+
/**
70+
* Returns the negotiated upload session URI, or {@code null} if session initiation is pending.
71+
*/
72+
public abstract @Nullable String getUploadUrl();
73+
74+
/** Returns the number of bytes confirmed as uploaded to the server so far. */
75+
public abstract long getBytesUploaded();
76+
77+
/** Returns the current state of the upload session. */
78+
public abstract State getState();
79+
80+
public abstract Builder toBuilder();
81+
82+
public static Builder newBuilder() {
83+
return new AutoValue_ResumableUploadProgress.Builder()
84+
.setBytesUploaded(0L)
85+
.setState(State.STARTING);
86+
}
87+
88+
@AutoValue.Builder
89+
public abstract static class Builder {
90+
public abstract Builder setUploadUrl(@Nullable String uploadUrl);
91+
92+
public abstract Builder setBytesUploaded(long bytesUploaded);
93+
94+
public abstract Builder setState(State state);
95+
96+
public abstract ResumableUploadProgress build();
97+
}
98+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.resumable;
31+
32+
import com.google.api.core.BetaApi;
33+
import org.jspecify.annotations.NullMarked;
34+
35+
/** A callback listener for observing progress and state transitions of a resumable upload. */
36+
@BetaApi
37+
@FunctionalInterface
38+
@NullMarked
39+
public interface ResumableUploadProgressListener {
40+
41+
/**
42+
* Invoked when upload progress or state changes.
43+
*
44+
* @param progress the current progress snapshot of the upload
45+
*/
46+
void onProgress(ResumableUploadProgress progress);
47+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.rpc;
31+
32+
import static com.google.common.base.Preconditions.checkNotNull;
33+
34+
import com.google.api.gax.resumable.ResumableUploadProgress;
35+
import com.google.api.gax.resumable.ResumableUploadProgressListener;
36+
import com.google.common.util.concurrent.MoreExecutors;
37+
import com.google.errorprone.annotations.concurrent.GuardedBy;
38+
import java.util.ArrayList;
39+
import java.util.List;
40+
import java.util.concurrent.ConcurrentLinkedQueue;
41+
import java.util.concurrent.Executor;
42+
import java.util.concurrent.RejectedExecutionException;
43+
import java.util.function.Consumer;
44+
import org.jspecify.annotations.NullMarked;
45+
46+
/** Publishes updates to listeners on resumable upload progress and state transitions. */
47+
@NullMarked
48+
class ResumableUploadProgressTracker {
49+
50+
private static final class RegisteredListener {
51+
final ResumableUploadProgressListener listener;
52+
final Executor sequentialExecutor;
53+
final ConcurrentLinkedQueue<ResumableUploadProgress> pendingUpdates =
54+
new ConcurrentLinkedQueue<>();
55+
56+
RegisteredListener(ResumableUploadProgressListener listener, Executor executor) {
57+
this.listener = listener;
58+
this.sequentialExecutor = MoreExecutors.newSequentialExecutor(executor);
59+
}
60+
61+
void drain() {
62+
try {
63+
sequentialExecutor.execute(
64+
() -> {
65+
ResumableUploadProgress status;
66+
while ((status = pendingUpdates.poll()) != null) {
67+
dispatchSafely(listener, status);
68+
}
69+
});
70+
} catch (RejectedExecutionException ignored) {
71+
// Executor exceptions are isolated from the upload pipeline
72+
}
73+
}
74+
}
75+
76+
private final Object lock = new Object();
77+
78+
@GuardedBy("lock")
79+
private final List<RegisteredListener> listeners = new ArrayList<>();
80+
81+
@GuardedBy("lock")
82+
private ResumableUploadProgress currentStatus;
83+
84+
ResumableUploadProgressTracker() {
85+
this.currentStatus =
86+
ResumableUploadProgress.newBuilder()
87+
.setState(ResumableUploadProgress.State.STARTING)
88+
.setBytesUploaded(0L)
89+
.build();
90+
}
91+
92+
void addListener(ResumableUploadProgressListener listener, Executor executor) {
93+
checkNotNull(listener, "listener must not be null");
94+
checkNotNull(executor, "executor must not be null");
95+
RegisteredListener entry = new RegisteredListener(listener, executor);
96+
synchronized (lock) {
97+
entry.pendingUpdates.add(this.currentStatus);
98+
if (!isTerminal()) {
99+
listeners.add(entry);
100+
}
101+
}
102+
entry.drain();
103+
}
104+
105+
ResumableUploadProgress getStatus() {
106+
synchronized (lock) {
107+
return currentStatus;
108+
}
109+
}
110+
111+
void onStarted(String uploadUrl) {
112+
checkNotNull(uploadUrl, "uploadUrl must not be null");
113+
transition(b -> b.setState(ResumableUploadProgress.State.STARTED).setUploadUrl(uploadUrl));
114+
}
115+
116+
void onChunkUploaded(long bytesUploaded) {
117+
transition(
118+
b -> b.setState(ResumableUploadProgress.State.UPLOADING).setBytesUploaded(bytesUploaded));
119+
}
120+
121+
void onRecovering() {
122+
transition(b -> b.setState(ResumableUploadProgress.State.RECOVERING));
123+
}
124+
125+
void onOffsetReceived(long committedOffset) {
126+
transition(
127+
b ->
128+
b.setState(ResumableUploadProgress.State.OFFSET_RECEIVED)
129+
.setBytesUploaded(committedOffset));
130+
}
131+
132+
void onFinalized(long totalBytes) {
133+
transition(
134+
b -> b.setState(ResumableUploadProgress.State.FINALIZED).setBytesUploaded(totalBytes));
135+
}
136+
137+
void onFailed() {
138+
transition(b -> b.setState(ResumableUploadProgress.State.FAILED));
139+
}
140+
141+
@GuardedBy("lock")
142+
private boolean isTerminal() {
143+
ResumableUploadProgress.State state = currentStatus.getState();
144+
return state == ResumableUploadProgress.State.FINALIZED
145+
|| state == ResumableUploadProgress.State.FAILED;
146+
}
147+
148+
private void transition(Consumer<ResumableUploadProgress.Builder> statusUpdater) {
149+
List<RegisteredListener> snapshot;
150+
synchronized (lock) {
151+
if (isTerminal()) {
152+
return;
153+
}
154+
ResumableUploadProgress.Builder builder = currentStatus.toBuilder();
155+
statusUpdater.accept(builder);
156+
ResumableUploadProgress newStatus = builder.build();
157+
this.currentStatus = newStatus;
158+
for (RegisteredListener entry : this.listeners) {
159+
entry.pendingUpdates.add(newStatus);
160+
}
161+
snapshot = new ArrayList<>(this.listeners);
162+
if (isTerminal()) {
163+
this.listeners.clear();
164+
}
165+
}
166+
for (RegisteredListener entry : snapshot) {
167+
entry.drain();
168+
}
169+
}
170+
171+
private static void dispatchSafely(
172+
ResumableUploadProgressListener listener, ResumableUploadProgress status) {
173+
try {
174+
listener.onProgress(status);
175+
} catch (Throwable ignored) {
176+
// Listener exceptions are isolated from the upload pipeline
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)